Merge remote-tracking branch 'origin/next' into feat/railpack

This commit is contained in:
Andras Bacsai committed 2026-03-25 16:45:30 +01:00
commit 207002dbb7
45 files changed
+1572 -272

No files matched your search

@@ -0,0 +1,67 @@
<?php
use App\Livewire\ActivityMonitor;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->otherTeam = Team::factory()->create();
});
test('hydrateActivity blocks access to another teams activity', 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]]);
$component = Livewire::test(ActivityMonitor::class)
->set('activityId', $otherActivity->id)
->assertSet('activity', null);
});
test('hydrateActivity allows access to own teams activity', function () {
$ownActivity = Activity::create([
'log_name' => 'default',
'description' => 'test activity',
'properties' => ['team_id' => $this->team->id],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
$component = Livewire::test(ActivityMonitor::class)
->set('activityId', $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 () {
$legacyActivity = Activity::create([
'log_name' => 'default',
'description' => 'legacy activity',
'properties' => [],
]);
$this->actingAs($this->user);
session(['currentTeam' => ['id' => $this->team->id]]);
$component = Livewire::test(ActivityMonitor::class)
->set('activityId', $legacyActivity->id);
expect($component->get('activity'))->not->toBeNull();
expect($component->get('activity')->id)->toBe($legacyActivity->id);
});
+76
View File
@@ -120,6 +120,82 @@ test('deleting s3 storage disables s3 on linked backups', function () {
expect($unrelatedBackup->save_s3)->toBeTruthy();
});
test('failed method does not overwrite successful backup status', function () {
$team = Team::factory()->create();
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => false,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
]);
$log = ScheduledDatabaseBackupExecution::create([
'uuid' => 'test-uuid-success-guard',
'database_name' => 'test_db',
'filename' => '/backup/test.dmp',
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
'message' => 'Backup completed successfully',
'size' => 1024,
]);
$job = new DatabaseBackupJob($backup);
$reflection = new ReflectionClass($job);
$teamProp = $reflection->getProperty('team');
$teamProp->setValue($job, $team);
$logUuidProp = $reflection->getProperty('backup_log_uuid');
$logUuidProp->setValue($job, 'test-uuid-success-guard');
// Simulate a post-backup failure (e.g. notification error)
$job->failed(new Exception('Request to the Resend API failed'));
$log->refresh();
expect($log->status)->toBe('success');
expect($log->message)->toBe('Backup completed successfully');
expect($log->size)->toBe(1024);
});
test('failed method updates status when backup was not successful', function () {
$team = Team::factory()->create();
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => false,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
]);
$log = ScheduledDatabaseBackupExecution::create([
'uuid' => 'test-uuid-pending-guard',
'database_name' => 'test_db',
'filename' => '/backup/test.dmp',
'scheduled_database_backup_id' => $backup->id,
'status' => 'pending',
]);
$job = new DatabaseBackupJob($backup);
$reflection = new ReflectionClass($job);
$teamProp = $reflection->getProperty('team');
$teamProp->setValue($job, $team);
$logUuidProp = $reflection->getProperty('backup_log_uuid');
$logUuidProp->setValue($job, 'test-uuid-pending-guard');
$job->failed(new Exception('Some real failure'));
$log->refresh();
expect($log->status)->toBe('failed');
expect($log->message)->toContain('Some real failure');
});
test('s3 storage has scheduled backups relationship', function () {
$team = Team::factory()->create();
+46 -3
View File
@@ -16,11 +16,12 @@ beforeEach(function () {
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
@@ -53,7 +54,7 @@ test('returns 404 when application uuid belongs to another team', function () {
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
@@ -78,3 +79,45 @@ test('returns 404 for nonexistent application uuid', function () {
$response->assertNotFound();
$response->assertJson(['message' => 'Application not found.']);
});
test('returns 404 when server uuid belongs to another team', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$response = $this->withHeaders(authHeaders())
->getJson("/api/v1/servers/{$otherServer->uuid}/domains");
$response->assertNotFound();
$response->assertJson(['message' => 'Server not found.']);
});
test('only returns domains for applications on the specified server', function () {
$application = Application::factory()->create([
'fqdn' => 'https://app-on-server.example.com',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherServer = Server::factory()->create(['team_id' => $this->team->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
$applicationOnOtherServer = Application::factory()->create([
'fqdn' => 'https://app-on-other-server.example.com',
'environment_id' => $this->environment->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains");
$response->assertOk();
$responseContent = $response->json();
$allDomains = collect($responseContent)->pluck('domains')->flatten()->toArray();
expect($allDomains)->toContain('app-on-server.example.com');
expect($allDomains)->not->toContain('app-on-other-server.example.com');
});
+247
View File
@@ -0,0 +1,247 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
$this->actingAs($this->user);
});
/**
* Simulate the preview .env generation logic from
* ApplicationDeploymentJob::generate_runtime_environment_variables()
* including the production fallback fix.
*/
function simulatePreviewEnvGeneration(Application $application): \Illuminate\Support\Collection
{
$sorted_environment_variables = $application->environment_variables->sortBy('id');
$sorted_environment_variables_preview = $application->environment_variables_preview->sortBy('id');
$envs = collect([]);
// Preview vars
$runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(fn ($env) => $env->is_runtime);
foreach ($runtime_environment_variables_preview as $env) {
$envs->push($env->key.'='.$env->real_value);
}
// Fallback: production vars not overridden by preview,
// only when preview vars are configured
if ($runtime_environment_variables_preview->isNotEmpty()) {
$previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray();
$fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) {
return $env->is_runtime && ! in_array($env->key, $previewKeys);
});
foreach ($fallback_production_vars as $env) {
$envs->push($env->key.'='.$env->real_value);
}
}
return $envs;
}
test('production vars fall back when preview vars exist but do not cover all keys', function () {
// Create two production vars (booted hook auto-creates preview copies)
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'secret123',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
EnvironmentVariable::create([
'key' => 'APP_KEY',
'value' => 'app_key_value',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Delete only the DB_PASSWORD preview copy — APP_KEY preview copy remains
$this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->delete();
$this->application->refresh();
// Preview has APP_KEY but not DB_PASSWORD
expect($this->application->environment_variables_preview()->where('key', 'APP_KEY')->count())->toBe(1);
expect($this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->count())->toBe(0);
$envs = simulatePreviewEnvGeneration($this->application);
$envString = $envs->implode("\n");
// DB_PASSWORD should fall back from production
expect($envString)->toContain('DB_PASSWORD=');
// APP_KEY should use the preview value
expect($envString)->toContain('APP_KEY=');
});
test('no fallback when no preview vars are configured at all', function () {
// Create a production-only var (booted hook auto-creates preview copy)
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'secret123',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Delete ALL preview copies — simulates no preview config
$this->application->environment_variables_preview()->delete();
$this->application->refresh();
expect($this->application->environment_variables_preview()->count())->toBe(0);
$envs = simulatePreviewEnvGeneration($this->application);
$envString = $envs->implode("\n");
// Should NOT fall back to production when no preview vars exist
expect($envString)->not->toContain('DB_PASSWORD=');
});
test('preview var overrides production var when both exist', function () {
// Create production var (auto-creates preview copy)
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'prod_password',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Update the auto-created preview copy with a different value
$this->application->environment_variables_preview()
->where('key', 'DB_PASSWORD')
->update(['value' => encrypt('preview_password')]);
$this->application->refresh();
$envs = simulatePreviewEnvGeneration($this->application);
// Should contain preview value only, not production
$envEntries = $envs->filter(fn ($e) => str_starts_with($e, 'DB_PASSWORD='));
expect($envEntries)->toHaveCount(1);
expect($envEntries->first())->toContain('preview_password');
});
test('preview-only var works without production counterpart', function () {
// Create a preview-only var directly (no production counterpart)
EnvironmentVariable::create([
'key' => 'PREVIEW_ONLY_VAR',
'value' => 'preview_value',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$this->application->refresh();
$envs = simulatePreviewEnvGeneration($this->application);
$envString = $envs->implode("\n");
expect($envString)->toContain('PREVIEW_ONLY_VAR=');
});
test('buildtime-only production vars are not included in preview fallback', function () {
// Create a runtime preview var so fallback is active
EnvironmentVariable::create([
'key' => 'SOME_PREVIEW_VAR',
'value' => 'preview_value',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Create a buildtime-only production var
EnvironmentVariable::create([
'key' => 'BUILD_SECRET',
'value' => 'build_only',
'is_preview' => false,
'is_runtime' => false,
'is_buildtime' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Delete the auto-created preview copy of BUILD_SECRET
$this->application->environment_variables_preview()->where('key', 'BUILD_SECRET')->delete();
$this->application->refresh();
$envs = simulatePreviewEnvGeneration($this->application);
$envString = $envs->implode("\n");
expect($envString)->not->toContain('BUILD_SECRET');
expect($envString)->toContain('SOME_PREVIEW_VAR=');
});
test('preview env var inherits is_runtime and is_buildtime from production var', function () {
// Create production var WITH explicit flags
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'secret123',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$preview = EnvironmentVariable::where('key', 'DB_PASSWORD')
->where('is_preview', true)
->where('resourceable_id', $this->application->id)
->first();
expect($preview)->not->toBeNull();
expect($preview->is_runtime)->toBeTrue();
expect($preview->is_buildtime)->toBeTrue();
});
test('preview env var gets correct defaults when production var created without explicit flags', function () {
// Simulate code paths (docker-compose parser, dev view bulk submit) that create
// env vars without explicitly setting is_runtime/is_buildtime
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'secret123',
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$preview = EnvironmentVariable::where('key', 'DB_PASSWORD')
->where('is_preview', true)
->where('resourceable_id', $this->application->id)
->first();
expect($preview)->not->toBeNull();
expect($preview->is_runtime)->toBeTrue();
expect($preview->is_buildtime)->toBeTrue();
});
@@ -50,6 +50,93 @@ describe('customer.subscription.created does not fall through to updated', funct
// Critical: stripe_invoice_paid must remain false — payment not yet confirmed
expect($subscription->stripe_invoice_paid)->toBeFalsy();
});
test('created event updates existing subscription instead of duplicating', function () {
Queue::fake();
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_old',
'stripe_customer_id' => 'cus_old',
'stripe_invoice_paid' => true,
]);
$event = [
'type' => 'customer.subscription.created',
'data' => [
'object' => [
'customer' => 'cus_new_123',
'id' => 'sub_new_123',
'metadata' => [
'team_id' => $this->team->id,
'user_id' => $this->user->id,
],
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1);
$subscription = Subscription::where('team_id', $this->team->id)->first();
expect($subscription->stripe_subscription_id)->toBe('sub_new_123');
expect($subscription->stripe_customer_id)->toBe('cus_new_123');
});
});
describe('checkout.session.completed', function () {
test('creates subscription for new team', function () {
Queue::fake();
$event = [
'type' => 'checkout.session.completed',
'data' => [
'object' => [
'client_reference_id' => $this->user->id.':'.$this->team->id,
'subscription' => 'sub_checkout_123',
'customer' => 'cus_checkout_123',
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
$subscription = Subscription::where('team_id', $this->team->id)->first();
expect($subscription)->not->toBeNull();
expect($subscription->stripe_invoice_paid)->toBeTruthy();
});
test('updates existing subscription instead of duplicating', function () {
Queue::fake();
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_old',
'stripe_customer_id' => 'cus_old',
'stripe_invoice_paid' => false,
]);
$event = [
'type' => 'checkout.session.completed',
'data' => [
'object' => [
'client_reference_id' => $this->user->id.':'.$this->team->id,
'subscription' => 'sub_checkout_new',
'customer' => 'cus_checkout_new',
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1);
$subscription = Subscription::where('team_id', $this->team->id)->first();
expect($subscription->stripe_subscription_id)->toBe('sub_checkout_new');
expect($subscription->stripe_invoice_paid)->toBeTruthy();
});
});
describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () {
+53
View File
@@ -0,0 +1,53 @@
<?php
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', true);
});
it('returns server limit when team is passed directly without session', function () {
$team = Team::factory()->create();
$limit = Team::serverLimit($team);
// self_hosted returns 999999999999
expect($limit)->toBe(999999999999);
});
it('returns 0 when no team is provided and no session exists', function () {
$limit = Team::serverLimit();
expect($limit)->toBe(0);
});
it('returns true for serverLimitReached when no team and no session', function () {
$result = Team::serverLimitReached();
expect($result)->toBeTrue();
});
it('returns false for serverLimitReached when team has servers under limit', function () {
$team = Team::factory()->create();
Server::factory()->create(['team_id' => $team->id]);
$result = Team::serverLimitReached($team);
// self_hosted has very high limit, 1 server is well under
expect($result)->toBeFalse();
});
it('returns true for serverLimitReached when team has servers at limit', function () {
config()->set('constants.coolify.self_hosted', false);
$team = Team::factory()->create(['custom_server_limit' => 1]);
Server::factory()->create(['team_id' => $team->id]);
$result = Team::serverLimitReached($team);
expect($result)->toBeTrue();
});
+67 -3
View File
@@ -10,20 +10,26 @@ beforeEach(function () {
Cache::spy();
});
function mockServerWithDbConfig(?string $savedConfig): object
function mockServerWithDbConfig(?string $savedConfig, string $proxyType = 'TRAEFIK'): object
{
$proxyAttributes = Mockery::mock(SchemalessAttributes::class);
$proxyAttributes->shouldReceive('get')
->with('last_saved_proxy_configuration')
->andReturn($savedConfig);
$proxyPath = match ($proxyType) {
'CADDY' => '/data/coolify/proxy/caddy',
'NGINX' => '/data/coolify/proxy/nginx',
default => '/data/coolify/proxy/',
};
$server = Mockery::mock('App\Models\Server');
$server->shouldIgnoreMissing();
$server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes);
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
$server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server');
$server->shouldReceive('proxyType')->andReturn('TRAEFIK');
$server->shouldReceive('proxyPath')->andReturn('/data/coolify/proxy');
$server->shouldReceive('proxyType')->andReturn($proxyType);
$server->shouldReceive('proxyPath')->andReturn($proxyPath);
return $server;
}
@@ -107,3 +113,61 @@ it('does not read from disk when DB config exists', function () {
expect($result)->toBe($savedConfig);
});
it('rejects stored Traefik config when proxy type is CADDY', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::spy();
$traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n";
$server = mockServerWithDbConfig($traefikConfig, 'CADDY');
// Config type mismatch should trigger regeneration, which will try
// backfillFromDisk (instant_remote_process) then generateDefault.
// Both will fail in test env, but the warning log proves mismatch was detected.
try {
GetProxyConfiguration::run($server);
} catch (\Throwable $e) {
// Expected — regeneration requires SSH/full server setup
}
Log::shouldHaveReceived('warning')
->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type'))
->once();
});
it('rejects stored Caddy config when proxy type is TRAEFIK', function () {
Log::swap(new \Illuminate\Log\LogManager(app()));
Log::spy();
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
$server = mockServerWithDbConfig($caddyConfig, 'TRAEFIK');
try {
GetProxyConfiguration::run($server);
} catch (\Throwable $e) {
// Expected — regeneration requires SSH/full server setup
}
Log::shouldHaveReceived('warning')
->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type'))
->once();
});
it('accepts stored Caddy config when proxy type is CADDY', function () {
$caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n";
$server = mockServerWithDbConfig($caddyConfig, 'CADDY');
$result = GetProxyConfiguration::run($server);
expect($result)->toBe($caddyConfig);
});
it('accepts stored config when YAML parsing fails', function () {
$invalidYaml = "this: is: not: [valid yaml: {{{}}}";
$server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK');
// Invalid YAML should not block — configMatchesProxyType returns true on parse failure
$result = GetProxyConfiguration::run($server);
expect($result)->toBe($invalidYaml);
});
@@ -4,7 +4,7 @@
* Unit tests to verify that Docker Compose environment variables
* do not overwrite user-saved values on redeploy.
*
* Regression test for GitHub issue #8885.
* Regression test for GitHub issues #8885 and #9136.
*/
it('uses firstOrCreate for simple variable references in serviceParser to preserve user values', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
@@ -14,8 +14,8 @@ it('uses firstOrCreate for simple variable references in serviceParser to preser
// This is the key === parsedValue branch
expect($parsersFile)->toContain(
"// Simple variable reference (e.g. DATABASE_URL: \${DATABASE_URL})\n".
" // Use firstOrCreate to avoid overwriting user-saved values on redeploy\n".
' $envVar = $resource->environment_variables()->firstOrCreate('
" // Ensure the variable exists in DB for .env generation and UI display\n".
' $resource->environment_variables()->firstOrCreate('
);
});
@@ -46,15 +46,15 @@ it('uses firstOrCreate for simple variable refs without default in serviceParser
expect($count)->toBe(1, 'serviceParser should use firstOrCreate for simple variable refs without default');
});
it('populates environment array with saved DB value instead of raw compose variable', function () {
it('does not replace self-referencing variable values in the environment array', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// After firstOrCreate, the environment should be populated with the DB value ($envVar->value)
// not the raw compose variable reference (e.g., ${DATABASE_URL})
// This pattern should appear in both parsers for all variable reference types
expect($parsersFile)->toContain('// Add the variable to the environment using the saved DB value');
expect($parsersFile)->toContain('$environment[$key->value()] = $envVar->value;');
expect($parsersFile)->toContain('$environment[$content] = $envVar->value;');
// Fix for #9136: self-referencing variables (KEY=${KEY}) must NOT have their ${VAR}
// reference replaced with the DB value in the compose environment section.
// Instead, the reference should stay intact so Docker Compose resolves from .env at deploy time.
// This prevents stale values when users update env vars without re-parsing compose.
expect($parsersFile)->toContain('Keep the ${VAR} reference in compose');
expect($parsersFile)->not->toContain('$environment[$key->value()] = $envVar->value;');
});
it('does not use updateOrCreate with value null for user-editable environment variables', function () {
+6 -5
View File
@@ -21,6 +21,8 @@ it('accepts valid RFC 1123 hostnames', function (string $hostname) {
'subdomain' => 'web.app.example.com',
'max label length' => str_repeat('a', 63),
'max total length' => str_repeat('a', 63).'.'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 59),
'uppercase hostname' => 'MyServer',
'mixed case fqdn' => 'MyServer.Example.COM',
]);
it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $expectedError) {
@@ -36,8 +38,7 @@ it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $exp
expect($failCalled)->toBeTrue();
expect($errorMessage)->toContain($expectedError);
})->with([
'uppercase letters' => ['MyServer', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
'underscore' => ['my_server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
'underscore' => ['my_server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'starts with hyphen' => ['-myserver', 'cannot start or end with a hyphen'],
'ends with hyphen' => ['myserver-', 'cannot start or end with a hyphen'],
'starts with dot' => ['.myserver', 'cannot start or end with a dot'],
@@ -46,9 +47,9 @@ it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $exp
'too long total' => [str_repeat('a', 254), 'must not exceed 253 characters'],
'label too long' => [str_repeat('a', 64), 'must be 1-63 characters'],
'empty label' => ['my..server', 'consecutive dots'],
'special characters' => ['my@server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
'space' => ['my server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
'shell metacharacters' => ['my;server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'],
'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'],
]);
it('accepts empty hostname', function () {
+82
View File
@@ -0,0 +1,82 @@
<?php
use App\Support\ValidationPatterns;
it('accepts valid names with common characters', function (string $name) {
expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
})->with([
'simple name' => 'My Server',
'name with hyphen' => 'my-server',
'name with underscore' => 'my_server',
'name with dot' => 'my.server',
'name with slash' => 'my/server',
'name with at sign' => 'user@host',
'name with ampersand' => 'Tom & Jerry',
'name with parentheses' => 'My Server (Production)',
'name with hash' => 'Server #1',
'name with comma' => 'Server, v2',
'name with colon' => 'Server: Production',
'name with plus' => 'C++ App',
'unicode name' => 'Ünïcödé Sërvér',
'unicode chinese' => '我的服务器',
'numeric name' => '12345',
'complex name' => 'App #3 (staging): v2.1+hotfix',
]);
it('rejects names with dangerous characters', function (string $name) {
expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(0);
})->with([
'semicolon' => 'my;server',
'pipe' => 'my|server',
'dollar sign' => 'my$server',
'backtick' => 'my`server',
'backslash' => 'my\\server',
'less than' => 'my<server',
'greater than' => 'my>server',
'curly braces' => 'my{server}',
'square brackets' => 'my[server]',
'tilde' => 'my~server',
'caret' => 'my^server',
'question mark' => 'my?server',
'percent' => 'my%server',
'double quote' => 'my"server',
'exclamation' => 'my!server',
'asterisk' => 'my*server',
]);
it('generates nameRules with correct defaults', function () {
$rules = ValidationPatterns::nameRules();
expect($rules)->toContain('required')
->toContain('string')
->toContain('min:3')
->toContain('max:255')
->toContain('regex:'.ValidationPatterns::NAME_PATTERN);
});
it('generates nullable nameRules when not required', function () {
$rules = ValidationPatterns::nameRules(required: false);
expect($rules)->toContain('nullable')
->not->toContain('required');
});
it('generates application names that comply with NAME_PATTERN', function (string $repo, string $branch) {
$name = generate_application_name($repo, $branch, 'testcuid');
expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
})->with([
'normal repo' => ['owner/my-app', 'main'],
'repo with dots' => ['repo.with.dots', 'feat/branch'],
'repo with plus' => ['C++ App', 'main'],
'branch with parens' => ['my-app', 'fix(auth)-login'],
'repo with exclamation' => ['my-app!', 'main'],
'repo with brackets' => ['app[test]', 'develop'],
]);
it('falls back to random name when repo produces empty name', function () {
$name = generate_application_name('!!!', 'main', 'testcuid');
expect(mb_strlen($name))->toBeGreaterThanOrEqual(3)
->and(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1);
});