Merge remote-tracking branch 'origin/next' into audit-policies

This commit is contained in:
Andras Bacsai committed 2026-05-13 11:15:29 +02:00
commit 6ab88c46b2
66 files changed
+2155 -792

No files matched your search

+1 -2
View File
@@ -85,8 +85,7 @@ describe('PATCH /api/v1/applications/{uuid} build_pack=railpack', function () {
$app->refresh();
expect($app->build_pack)->toBe('railpack');
expect($app->dockerfile)->toBeNull();
// NOTE: dockerfile_location is normalized to '/Dockerfile' by the model
// mutator when set to null, so we cannot assert it becomes null here.
expect($app->dockerfile_location)->toBeNull();
expect($app->dockerfile_target_build)->toBeNull();
expect((bool) $app->custom_healthcheck_found)->toBeFalse();
});
@@ -6,6 +6,7 @@ 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;
@@ -29,11 +30,12 @@ beforeEach(function () {
Notification::fake();
});
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): PersonalAccessToken
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();
@@ -41,14 +43,30 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): Person
describe('ApiTokenExpirationWarningJob', function () {
test('notifies team when token expires within 24h', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(23));
$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('rate limiter prevents duplicate warnings on repeat runs', function () {
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();
@@ -57,6 +75,35 @@ describe('ApiTokenExpirationWarningJob', function () {
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));
@@ -240,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,97 @@
<?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->isLegacyFallback())->toBeTrue()
->and($diff->isChanged())->toBeTrue();
});
it('falls back to legacy configuration hash when no deployment snapshot exists', function () {
$application = configurationChangedTestApplication();
$application->isConfigurationChanged(save: true);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
$application->update(['build_command' => 'pnpm build']);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isLegacyFallback())->toBeTrue()
->and($application->pendingDeploymentConfigurationDiff()->isChanged())->toBeTrue();
});
@@ -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);
});
});
@@ -67,7 +67,7 @@ test('existing application buildpack selector lists nixpacks before railpack', f
], false);
});
test('existing application shows railpack beta badge in build helper copy', function () {
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,
@@ -81,6 +81,5 @@ test('existing application shows railpack beta badge in build helper copy', func
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSee('Railpack')
->assertSee('Beta');
->assertSee('Railpack (Beta)');
});
@@ -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');
});
@@ -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,158 @@
<?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('changed')
->assertSee('Set')
->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('Not set')
->assertSee('To')
->assertSee('Set')
->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);
});
@@ -299,6 +299,7 @@ it('creates ApplicationSetting with all fillable attributes', function () {
'inject_build_args_to_dockerfile' => true,
'include_source_commit_in_build' => true,
'docker_images_to_keep' => 5,
'stop_grace_period' => 300,
]);
expect($setting->exists)->toBeTrue();
@@ -309,6 +310,7 @@ it('creates ApplicationSetting with all fillable attributes', function () {
expect($setting->custom_internal_name)->toBe('my-custom-app');
expect($setting->is_spa)->toBeTrue();
expect($setting->docker_images_to_keep)->toBe(5);
expect($setting->stop_grace_period)->toBe(300);
});
it('creates ServerSetting with all fillable attributes', function () {
@@ -38,14 +38,12 @@ describe('new application buildpack defaults', function () {
test('public repository flow keeps railpack available after branch lookup', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('branchFound', true)
->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)'])
->assertSee('Beta');
->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']);
});
test('deploy key repository flow shows railpack beta label in build pack selector', function () {
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)')
->assertSee('Beta');
->assertSee('Railpack (Beta)');
});
});
@@ -11,7 +11,7 @@ use App\Models\ApplicationSetting;
it('casts is_static to boolean when true', function () {
$setting = new ApplicationSetting;
$setting->is_static = true;
$setting->setRawAttributes(['is_static' => true]);
// Verify it's cast to boolean
expect($setting->is_static)->toBeTrue()
@@ -20,7 +20,7 @@ it('casts is_static to boolean when true', function () {
it('casts is_static to boolean when false', function () {
$setting = new ApplicationSetting;
$setting->is_static = false;
$setting->setRawAttributes(['is_static' => false]);
// Verify it's cast to boolean
expect($setting->is_static)->toBeFalse()
@@ -29,7 +29,7 @@ it('casts is_static to boolean when false', function () {
it('casts is_static from string "1" to boolean true', function () {
$setting = new ApplicationSetting;
$setting->is_static = '1';
$setting->setRawAttributes(['is_static' => '1']);
// Should cast string to boolean
expect($setting->is_static)->toBeTrue()
@@ -38,7 +38,7 @@ it('casts is_static from string "1" to boolean true', function () {
it('casts is_static from string "0" to boolean false', function () {
$setting = new ApplicationSetting;
$setting->is_static = '0';
$setting->setRawAttributes(['is_static' => '0']);
// Should cast string to boolean
expect($setting->is_static)->toBeFalse()
@@ -47,7 +47,7 @@ it('casts is_static from string "0" to boolean false', function () {
it('casts is_static from integer 1 to boolean true', function () {
$setting = new ApplicationSetting;
$setting->is_static = 1;
$setting->setRawAttributes(['is_static' => 1]);
// Should cast integer to boolean
expect($setting->is_static)->toBeTrue()
@@ -56,7 +56,7 @@ it('casts is_static from integer 1 to boolean true', function () {
it('casts is_static from integer 0 to boolean false', function () {
$setting = new ApplicationSetting;
$setting->is_static = 0;
$setting->setRawAttributes(['is_static' => 0]);
// Should cast integer to boolean
expect($setting->is_static)->toBeFalse()
@@ -103,3 +103,65 @@ it('casts all boolean fields correctly', function () {
->and($casts[$field])->toBe('boolean');
}
});
it('casts stop_grace_period to integer', function () {
$setting = new ApplicationSetting;
$casts = $setting->getCasts();
expect($casts)->toHaveKey('stop_grace_period')
->and($casts['stop_grace_period'])->toBe('integer');
});
it('handles null stop_grace_period for default behavior', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = null;
expect($setting->stop_grace_period)->toBeNull();
});
it('casts stop_grace_period from string to integer', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = '60';
expect($setting->stop_grace_period)->toBe(60)
->and($setting->stop_grace_period)->toBeInt();
});
it('casts stop_grace_period zero to integer (documents fallback trigger)', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = 0;
expect($setting->stop_grace_period)->toBe(0)
->and($setting->stop_grace_period)->toBeInt();
});
it('casts stop_grace_period negative value to integer (documents fallback trigger)', function () {
$setting = new ApplicationSetting;
$setting->stop_grace_period = -10;
expect($setting->stop_grace_period)->toBe(-10)
->and($setting->stop_grace_period)->toBeInt();
});
it('resolves valid stop grace periods', function (?int $storedValue, int $expectedValue) {
$setting = new ApplicationSetting;
$setting->stop_grace_period = $storedValue;
expect($setting->stopGracePeriodSeconds())->toBe($expectedValue);
})->with([
'minimum' => [MIN_STOP_GRACE_PERIOD_SECONDS, MIN_STOP_GRACE_PERIOD_SECONDS],
'custom' => [300, 300],
'maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS, MAX_STOP_GRACE_PERIOD_SECONDS],
]);
it('falls back to default stop grace period for invalid stored values', function (?int $storedValue) {
$setting = new ApplicationSetting;
$setting->stop_grace_period = $storedValue;
expect($setting->stopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS);
})->with([
'null' => [null],
'zero' => [0],
'negative' => [-10],
'above maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS + 1],
]);
@@ -0,0 +1,123 @@
<?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;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function snapshotTestApplication(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',
'fqdn' => 'https://example.com',
'build_command' => 'npm run build',
'start_command' => 'npm run start',
], $attributes));
}
function markSnapshotTestApplicationDeployed(Application $application): ApplicationDeploymentQueue
{
$deployment = ApplicationDeploymentQueue::create([
'application_id' => (string) $application->id,
'deployment_uuid' => (string) Str::uuid(),
'status' => 'finished',
'commit' => 'HEAD',
]);
$application->markDeploymentConfigurationApplied($deployment);
return $deployment->refresh();
}
it('does not report preview deployment toggles as pending production configuration changes', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->settings->update(['is_preview_deployments_enabled' => true]);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
});
it('detects build-impacting changes', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->update(['build_command' => 'pnpm build']);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->isChanged())->toBeTrue()
->and($diff->requiresBuild())->toBeTrue()
->and(collect($diff->changes())->pluck('label'))->toContain('Build command');
});
it('detects redeploy-only domain changes', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
$application->update(['fqdn' => 'https://new.example.com']);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->isChanged())->toBeTrue()
->and($diff->requiresBuild())->toBeFalse()
->and(collect($diff->changes())->pluck('label'))->toContain('Domains');
});
it('detects environment variable value changes without exposing secret values', function () {
$application = snapshotTestApplication();
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,
]);
markSnapshotTestApplicationDeployed($application->refresh());
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBe('Changed')
->and($change['old_display_value'])->toBe('Set')
->and($change['new_display_value'])->toBe('Set')
->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
});
it('describes added environment variables as set without exposing secret values', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($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,
]);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('Not set')
->and($change['new_display_value'])->toBe('Set')
->and(json_encode($diff->toArray()))->not->toContain('new-secret');
});