diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index 371c860ca..a9a7de878 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -22,6 +22,12 @@ class EditDomain extends Component public $forceSaveDomains = false; + public $showPortWarningModal = false; + + public $forceRemovePort = false; + + public $requiredPort = null; + #[Validate(['nullable'])] public ?string $fqdn = null; @@ -33,6 +39,7 @@ public function mount() { $this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId); $this->authorize('view', $this->application); + $this->requiredPort = $this->application->service->getRequiredPort(); $this->syncData(); } @@ -58,6 +65,19 @@ public function confirmDomainUsage() $this->submit(); } + public function confirmRemovePort() + { + $this->forceRemovePort = true; + $this->showPortWarningModal = false; + $this->submit(); + } + + public function cancelRemovePort() + { + $this->showPortWarningModal = false; + $this->syncData(); // Reset to original FQDN + } + public function submit() { try { @@ -91,6 +111,41 @@ public function submit() $this->forceSaveDomains = false; } + // Check for required port + if (! $this->forceRemovePort) { + $service = $this->application->service; + $requiredPort = $service->getRequiredPort(); + + if ($requiredPort !== null) { + // Check if all FQDNs have a port + $fqdns = str($this->fqdn)->trim()->explode(','); + $missingPort = false; + + foreach ($fqdns as $fqdn) { + $fqdn = trim($fqdn); + if (empty($fqdn)) { + continue; + } + + $port = ServiceApplication::extractPortFromUrl($fqdn); + if ($port === null) { + $missingPort = true; + break; + } + } + + if ($missingPort) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + + return; + } + } + } else { + // Reset the force flag after using it + $this->forceRemovePort = false; + } + $this->validate(); $this->application->save(); $this->application->refresh(); diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 09392ab09..1d8d8b247 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -30,6 +30,12 @@ class ServiceApplicationView extends Component public $forceSaveDomains = false; + public $showPortWarningModal = false; + + public $forceRemovePort = false; + + public $requiredPort = null; + #[Validate(['nullable'])] public ?string $humanName = null; @@ -129,12 +135,26 @@ public function mount() try { $this->parameters = get_route_parameters(); $this->authorize('view', $this->application); + $this->requiredPort = $this->application->service->getRequiredPort(); $this->syncData(); } catch (\Throwable $e) { return handleError($e, $this); } } + public function confirmRemovePort() + { + $this->forceRemovePort = true; + $this->showPortWarningModal = false; + $this->submit(); + } + + public function cancelRemovePort() + { + $this->showPortWarningModal = false; + $this->syncData(); // Reset to original FQDN + } + public function syncData(bool $toModel = false): void { if ($toModel) { @@ -246,6 +266,41 @@ public function submit() $this->forceSaveDomains = false; } + // Check for required port + if (! $this->forceRemovePort) { + $service = $this->application->service; + $requiredPort = $service->getRequiredPort(); + + if ($requiredPort !== null) { + // Check if all FQDNs have a port + $fqdns = str($this->fqdn)->trim()->explode(','); + $missingPort = false; + + foreach ($fqdns as $fqdn) { + $fqdn = trim($fqdn); + if (empty($fqdn)) { + continue; + } + + $port = ServiceApplication::extractPortFromUrl($fqdn); + if ($port === null) { + $missingPort = true; + break; + } + } + + if ($missingPort) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + + return; + } + } + } else { + // Reset the force flag after using it + $this->forceRemovePort = false; + } + $this->validate(); $this->application->save(); $this->application->refresh(); diff --git a/app/Models/Service.php b/app/Models/Service.php index c4b8623e0..12d3d6a11 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1184,6 +1184,31 @@ public function documentation() return data_get($service, 'documentation', config('constants.urls.docs')); } + /** + * Get the required port for this service from the template definition. + */ + public function getRequiredPort(): ?int + { + try { + $services = get_service_templates(); + $serviceName = str($this->name)->beforeLast('-')->value(); + $service = data_get($services, $serviceName, []); + $port = data_get($service, 'port'); + + return $port ? (int) $port : null; + } catch (\Throwable) { + return null; + } + } + + /** + * Check if this service requires a port to function correctly. + */ + public function requiresPort(): bool + { + return $this->getRequiredPort() !== null; + } + public function applications() { return $this->hasMany(ServiceApplication::class); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 5cafc9042..49bd56206 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -118,6 +118,53 @@ public function fqdns(): Attribute ); } + /** + * Extract port number from a given FQDN URL. + * Returns null if no port is specified. + */ + public static function extractPortFromUrl(string $url): ?int + { + try { + // Ensure URL has a scheme for proper parsing + if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) { + $url = 'http://'.$url; + } + + $parsed = parse_url($url); + $port = $parsed['port'] ?? null; + + return $port ? (int) $port : null; + } catch (\Throwable) { + return null; + } + } + + /** + * Check if all FQDNs have a port specified. + */ + public function allFqdnsHavePort(): bool + { + if (is_null($this->fqdn) || $this->fqdn === '') { + return false; + } + + $fqdns = explode(',', $this->fqdn); + + foreach ($fqdns as $fqdn) { + $fqdn = trim($fqdn); + if (empty($fqdn)) { + continue; + } + + $port = self::extractPortFromUrl($fqdn); + if ($port === null) { + return false; + } + } + + return true; + } + public function getFilesFromServer(bool $isInit = false) { getFilesystemVolumesFromServer($this, $isInit); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index beb643d7d..1deec45d7 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1164,17 +1164,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $environment = $environment->filter(function ($value, $key) { return ! str($key)->startsWith('SERVICE_FQDN_'); })->map(function ($value, $key) use ($resource) { - // Preserve empty strings; only override if database value exists and is non-empty - // This is important because empty strings and null have different semantics in Docker: + // Preserve empty strings and null values with correct Docker Compose semantics: // - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy") - // - Null: Variable is unset/removed from container environment - if (str($value)->isEmpty()) { + // - Null: Variable is unset/removed from container environment (may inherit from host) + if ($value === null) { + // User explicitly wants variable unset - respect that + // NEVER override from database - null means "inherit from environment" + // Keep as null (will be excluded from container environment) + } elseif ($value === '') { + // Empty string - allow database override for backward compatibility $dbEnv = $resource->environment_variables()->where('key', $key)->first(); // Only use database override if it exists AND has a non-empty value if ($dbEnv && str($dbEnv->value)->isNotEmpty()) { $value = $dbEnv->value; } - // Keep empty string as-is (don't convert to null) + // Otherwise keep empty string as-is } return $value; @@ -1605,21 +1609,22 @@ function serviceParser(Service $resource): Collection ]); } if (substr_count(str($key)->value(), '_') === 3) { - $newKey = str($key)->beforeLast('_'); + // For port-specific variables (e.g., SERVICE_FQDN_UMAMI_3000), + // keep the port suffix in the key and use the URL with port $resource->environment_variables()->updateOrCreate([ - 'key' => $newKey->value(), + 'key' => $key->value(), 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $fqdn, + 'value' => $fqdnWithPort, 'is_preview' => false, ]); $resource->environment_variables()->updateOrCreate([ - 'key' => $newKey->value(), + 'key' => $key->value(), 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $url, + 'value' => $urlWithPort, 'is_preview' => false, ]); } @@ -2138,17 +2143,21 @@ function serviceParser(Service $resource): Collection $environment = $environment->filter(function ($value, $key) { return ! str($key)->startsWith('SERVICE_FQDN_'); })->map(function ($value, $key) use ($resource) { - // Preserve empty strings; only override if database value exists and is non-empty - // This is important because empty strings and null have different semantics in Docker: + // Preserve empty strings and null values with correct Docker Compose semantics: // - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy") - // - Null: Variable is unset/removed from container environment - if (str($value)->isEmpty()) { + // - Null: Variable is unset/removed from container environment (may inherit from host) + if ($value === null) { + // User explicitly wants variable unset - respect that + // NEVER override from database - null means "inherit from environment" + // Keep as null (will be excluded from container environment) + } elseif ($value === '') { + // Empty string - allow database override for backward compatibility $dbEnv = $resource->environment_variables()->where('key', $key)->first(); // Only use database override if it exists AND has a non-empty value if ($dbEnv && str($dbEnv->value)->isNotEmpty()) { $value = $dbEnv->value; } - // Keep empty string as-is (don't convert to null) + // Otherwise keep empty string as-is } return $value; diff --git a/resources/views/livewire/project/service/edit-domain.blade.php b/resources/views/livewire/project/service/edit-domain.blade.php index a126eca5b..0691146f6 100644 --- a/resources/views/livewire/project/service/edit-domain.blade.php +++ b/resources/views/livewire/project/service/edit-domain.blade.php @@ -1,7 +1,13 @@
-
Note: If a service has a defined port, do not delete it.
If you want to use your custom - domain, you can add it with a port.
+ @if($requiredPort) + + This service requires port {{ $requiredPort }} to function correctly. All domains must include this port number (or any other port if you know what you're doing). +

+ Example: http://app.coolify.io:{{ $requiredPort }} +
+ @endif + @@ -18,4 +24,61 @@ + + @if ($showPortWarningModal) +
+ +
+ @endif
diff --git a/resources/views/livewire/project/service/service-application-view.blade.php b/resources/views/livewire/project/service/service-application-view.blade.php index b95dc6540..5fb4a62d0 100644 --- a/resources/views/livewire/project/service/service-application-view.blade.php +++ b/resources/views/livewire/project/service/service-application-view.blade.php @@ -22,6 +22,14 @@ @endcan
+ @if($requiredPort && !$application->serviceType()?->contains(str($application->image)->before(':'))) + + This service requires port {{ $requiredPort }} to function correctly. All domains must include this port number (or any other port if you know what you're doing). +

+ Example: http://app.coolify.io:{{ $requiredPort }} +
+ @endif +
@@ -68,9 +76,9 @@
-
    @@ -81,4 +89,61 @@
+ + @if ($showPortWarningModal) +
+ +
+ @endif
diff --git a/tests/Feature/DatabaseBackupCreationApiTest.php b/tests/Feature/DatabaseBackupCreationApiTest.php index 16a65dff2..893141de3 100644 --- a/tests/Feature/DatabaseBackupCreationApiTest.php +++ b/tests/Feature/DatabaseBackupCreationApiTest.php @@ -1,7 +1,5 @@ user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + + // Create server + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + + // Create standalone docker destination + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + ]); + + // Create project and environment + $this->project = Project::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + // Create service with a name that maps to a template with required port + $this->service = Service::factory()->create([ + 'name' => 'supabase-test123', + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + // Create service application + $this->serviceApplication = ServiceApplication::factory()->create([ + 'service_id' => $this->service->id, + 'fqdn' => 'http://example.com:8000', + ]); + + // Mock get_service_templates to return a service with required port + if (! function_exists('get_service_templates_mock')) { + function get_service_templates_mock() + { + return collect([ + 'supabase' => [ + 'name' => 'Supabase', + 'port' => '8000', + 'documentation' => 'https://supabase.com', + ], + ]); + } + } +}); + +it('loads the EditDomain component with required port', function () { + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->assertSet('requiredPort', 8000) + ->assertSet('fqdn', 'http://example.com:8000') + ->assertOk(); +}); + +it('shows warning modal when trying to remove required port', function () { + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('fqdn', 'http://example.com') // Remove port + ->call('submit') + ->assertSet('showPortWarningModal', true) + ->assertSet('requiredPort', 8000); +}); + +it('allows port removal when user confirms', function () { + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('fqdn', 'http://example.com') // Remove port + ->call('submit') + ->assertSet('showPortWarningModal', true) + ->call('confirmRemovePort') + ->assertSet('showPortWarningModal', false); + + // Verify the FQDN was updated in database + $this->serviceApplication->refresh(); + expect($this->serviceApplication->fqdn)->toBe('http://example.com'); +}); + +it('cancels port removal when user cancels', function () { + $originalFqdn = $this->serviceApplication->fqdn; + + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('fqdn', 'http://example.com') // Remove port + ->call('submit') + ->assertSet('showPortWarningModal', true) + ->call('cancelRemovePort') + ->assertSet('showPortWarningModal', false) + ->assertSet('fqdn', $originalFqdn); // Should revert to original +}); + +it('allows saving when port is changed to different port', function () { + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('fqdn', 'http://example.com:3000') // Change to different port + ->call('submit') + ->assertSet('showPortWarningModal', false); // Should not show warning + + // Verify the FQDN was updated + $this->serviceApplication->refresh(); + expect($this->serviceApplication->fqdn)->toBe('http://example.com:3000'); +}); + +it('allows saving when all domains have ports (multiple domains)', function () { + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('fqdn', 'http://example.com:8000,https://app.example.com:8080') + ->call('submit') + ->assertSet('showPortWarningModal', false); // Should not show warning +}); + +it('shows warning when at least one domain is missing port (multiple domains)', function () { + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('fqdn', 'http://example.com:8000,https://app.example.com') // Second domain missing port + ->call('submit') + ->assertSet('showPortWarningModal', true); +}); + +it('does not show warning for services without required port', function () { + // Create a service without required port (e.g., cloudflared) + $serviceWithoutPort = Service::factory()->create([ + 'name' => 'cloudflared-test456', + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $appWithoutPort = ServiceApplication::factory()->create([ + 'service_id' => $serviceWithoutPort->id, + 'fqdn' => 'http://example.com', + ]); + + Livewire::test(EditDomain::class, ['applicationId' => $appWithoutPort->id]) + ->set('fqdn', 'http://example.com') // No port + ->call('submit') + ->assertSet('showPortWarningModal', false); // Should not show warning +}); diff --git a/tests/Unit/DockerComposeEmptyStringPreservationTest.php b/tests/Unit/DockerComposeEmptyStringPreservationTest.php index 71f59ce81..df654f2ea 100644 --- a/tests/Unit/DockerComposeEmptyStringPreservationTest.php +++ b/tests/Unit/DockerComposeEmptyStringPreservationTest.php @@ -19,13 +19,13 @@ $hasApplicationParser = str_contains($parsersFile, 'function applicationParser('); expect($hasApplicationParser)->toBeTrue('applicationParser function should exist'); - // The code should NOT unconditionally set $value = null for empty strings - // Instead, it should preserve empty strings when no database override exists + // The code should distinguish between null and empty string + // Check for the pattern where we explicitly check for null vs empty string + $hasNullCheck = str_contains($parsersFile, 'if ($value === null)'); + $hasEmptyStringCheck = str_contains($parsersFile, "} elseif (\$value === '') {"); - // Check for the pattern where we only override with database values when they're non-empty - // We're checking the fix is in place by looking for the logic pattern - $pattern1 = str_contains($parsersFile, 'if (str($value)->isEmpty())'); - expect($pattern1)->toBeTrue('Empty string check should exist'); + expect($hasNullCheck)->toBeTrue('Should have explicit null check'); + expect($hasEmptyStringCheck)->toBeTrue('Should have explicit empty string check'); }); it('ensures parsers.php preserves empty strings in service parser', function () { @@ -35,10 +35,13 @@ $hasServiceParser = str_contains($parsersFile, 'function serviceParser('); expect($hasServiceParser)->toBeTrue('serviceParser function should exist'); - // The code should NOT unconditionally set $value = null for empty strings - // Same check as above for service parser - $pattern1 = str_contains($parsersFile, 'if (str($value)->isEmpty())'); - expect($pattern1)->toBeTrue('Empty string check should exist'); + // The code should distinguish between null and empty string + // Same check as application parser + $hasNullCheck = str_contains($parsersFile, 'if ($value === null)'); + $hasEmptyStringCheck = str_contains($parsersFile, "} elseif (\$value === '') {"); + + expect($hasNullCheck)->toBeTrue('Should have explicit null check'); + expect($hasEmptyStringCheck)->toBeTrue('Should have explicit empty string check'); }); it('verifies YAML parsing preserves empty strings correctly', function () { @@ -186,3 +189,108 @@ expect(isset($arrayWithEmpty['key']))->toBeTrue(); expect(isset($arrayWithNull['key']))->toBeFalse(); }); + +it('verifies YAML null syntax options all produce PHP null', function () { + // Test all three ways to write null in YAML + $yamlWithNullSyntax = <<<'YAML' +environment: + VAR_NO_VALUE: + VAR_EXPLICIT_NULL: null + VAR_TILDE: ~ + VAR_EMPTY_STRING: "" +YAML; + + $parsed = Yaml::parse($yamlWithNullSyntax); + + // All three null syntaxes should produce PHP null + expect($parsed['environment']['VAR_NO_VALUE'])->toBeNull(); + expect($parsed['environment']['VAR_EXPLICIT_NULL'])->toBeNull(); + expect($parsed['environment']['VAR_TILDE'])->toBeNull(); + + // Empty string should remain empty string + expect($parsed['environment']['VAR_EMPTY_STRING'])->toBe(''); +}); + +it('verifies null round-trip through YAML', function () { + // Test full round-trip: null -> YAML -> parse -> serialize -> parse + $original = [ + 'environment' => [ + 'NULL_VAR' => null, + 'EMPTY_VAR' => '', + 'VALUE_VAR' => 'localhost', + ], + ]; + + // Serialize to YAML + $yaml1 = Yaml::dump($original, 10, 2); + + // Parse back + $parsed1 = Yaml::parse($yaml1); + + // Verify types are preserved + expect($parsed1['environment']['NULL_VAR'])->toBeNull(); + expect($parsed1['environment']['EMPTY_VAR'])->toBe(''); + expect($parsed1['environment']['VALUE_VAR'])->toBe('localhost'); + + // Serialize again + $yaml2 = Yaml::dump($parsed1, 10, 2); + + // Parse again + $parsed2 = Yaml::parse($yaml2); + + // Should still have correct types + expect($parsed2['environment']['NULL_VAR'])->toBeNull(); + expect($parsed2['environment']['EMPTY_VAR'])->toBe(''); + expect($parsed2['environment']['VALUE_VAR'])->toBe('localhost'); + + // Both YAML representations should be equivalent + expect($yaml1)->toBe($yaml2); +}); + +it('verifies null vs empty string behavior difference', function () { + // Document the critical difference between null and empty string + + // Null in YAML + $yamlNull = "VAR: null\n"; + $parsedNull = Yaml::parse($yamlNull); + expect($parsedNull['VAR'])->toBeNull(); + + // Empty string in YAML + $yamlEmpty = "VAR: \"\"\n"; + $parsedEmpty = Yaml::parse($yamlEmpty); + expect($parsedEmpty['VAR'])->toBe(''); + + // They should NOT be equal + expect($parsedNull['VAR'] === $parsedEmpty['VAR'])->toBeFalse(); + + // Verify type differences + expect(is_null($parsedNull['VAR']))->toBeTrue(); + expect(is_string($parsedEmpty['VAR']))->toBeTrue(); +}); + +it('verifies parser logic distinguishes null from empty string', function () { + // Test the exact === comparison behavior + $nullValue = null; + $emptyString = ''; + + // PHP strict comparison + expect($nullValue === null)->toBeTrue(); + expect($emptyString === '')->toBeTrue(); + expect($nullValue === $emptyString)->toBeFalse(); + + // This is what the parser should use for correct behavior + if ($nullValue === null) { + $nullHandled = true; + } else { + $nullHandled = false; + } + + if ($emptyString === '') { + $emptyHandled = true; + } else { + $emptyHandled = false; + } + + expect($nullHandled)->toBeTrue(); + expect($emptyHandled)->toBeTrue(); +}); diff --git a/tests/Unit/Policies/PrivateKeyPolicyTest.php b/tests/Unit/Policies/PrivateKeyPolicyTest.php index dd0037403..6844d92f7 100644 --- a/tests/Unit/Policies/PrivateKeyPolicyTest.php +++ b/tests/Unit/Policies/PrivateKeyPolicyTest.php @@ -1,6 +1,5 @@ toBeTrue('Should have comment about port-specific variables'); + expect($usesFqdnWithPort)->toBeTrue('Should use $fqdnWithPort for port variables'); + expect($usesUrlWithPort)->toBeTrue('Should use $urlWithPort for port variables'); +}); + +it('verifies SERVICE_URL variable naming convention', function () { + // Test the naming convention for port-specific variables + + // Base variable (no port): SERVICE_URL_UMAMI + $baseKey = 'SERVICE_URL_UMAMI'; + expect(substr_count($baseKey, '_'))->toBe(2); + + // Port-specific variable: SERVICE_URL_UMAMI_3000 + $portKey = 'SERVICE_URL_UMAMI_3000'; + expect(substr_count($portKey, '_'))->toBe(3); + + // Extract service name + $serviceName = str($portKey)->after('SERVICE_URL_')->beforeLast('_')->lower()->value(); + expect($serviceName)->toBe('umami'); + + // Extract port + $port = str($portKey)->afterLast('_')->value(); + expect($port)->toBe('3000'); +}); + +it('verifies SERVICE_FQDN variable naming convention', function () { + // Test the naming convention for port-specific FQDN variables + + // Base variable (no port): SERVICE_FQDN_POSTGRES + $baseKey = 'SERVICE_FQDN_POSTGRES'; + expect(substr_count($baseKey, '_'))->toBe(2); + + // Port-specific variable: SERVICE_FQDN_POSTGRES_5432 + $portKey = 'SERVICE_FQDN_POSTGRES_5432'; + expect(substr_count($portKey, '_'))->toBe(3); + + // Extract service name + $serviceName = str($portKey)->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value(); + expect($serviceName)->toBe('postgres'); + + // Extract port + $port = str($portKey)->afterLast('_')->value(); + expect($port)->toBe('5432'); +}); + +it('verifies URL with port format', function () { + // Test that URLs with ports are formatted correctly + $baseUrl = 'http://umami-abc123.domain.com'; + $port = '3000'; + + $urlWithPort = "$baseUrl:$port"; + + expect($urlWithPort)->toBe('http://umami-abc123.domain.com:3000'); + expect($urlWithPort)->toContain(':3000'); +}); + +it('verifies FQDN with port format', function () { + // Test that FQDNs with ports are formatted correctly + $baseFqdn = 'postgres-xyz789.domain.com'; + $port = '5432'; + + $fqdnWithPort = "$baseFqdn:$port"; + + expect($fqdnWithPort)->toBe('postgres-xyz789.domain.com:5432'); + expect($fqdnWithPort)->toContain(':5432'); +}); + +it('verifies port extraction from variable name', function () { + // Test extracting port from various variable names + $tests = [ + 'SERVICE_URL_APP_3000' => '3000', + 'SERVICE_URL_API_8080' => '8080', + 'SERVICE_FQDN_DB_5432' => '5432', + 'SERVICE_FQDN_REDIS_6379' => '6379', + ]; + + foreach ($tests as $varName => $expectedPort) { + $port = str($varName)->afterLast('_')->value(); + expect($port)->toBe($expectedPort, "Port extraction failed for $varName"); + } +}); + +it('verifies service name extraction with port suffix', function () { + // Test extracting service name when port is present + $tests = [ + 'SERVICE_URL_APP_3000' => 'app', + 'SERVICE_URL_MY_API_8080' => 'my_api', + 'SERVICE_FQDN_DB_5432' => 'db', + 'SERVICE_FQDN_REDIS_CACHE_6379' => 'redis_cache', + ]; + + foreach ($tests as $varName => $expectedService) { + if (str($varName)->startsWith('SERVICE_URL_')) { + $serviceName = str($varName)->after('SERVICE_URL_')->beforeLast('_')->lower()->value(); + } else { + $serviceName = str($varName)->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value(); + } + expect($serviceName)->toBe($expectedService, "Service name extraction failed for $varName"); + } +}); + +it('verifies distinction between base and port-specific variables', function () { + // Test that base and port-specific variables are different + $baseUrl = 'SERVICE_URL_UMAMI'; + $portUrl = 'SERVICE_URL_UMAMI_3000'; + + expect($baseUrl)->not->toBe($portUrl); + expect(substr_count($baseUrl, '_'))->toBe(2); + expect(substr_count($portUrl, '_'))->toBe(3); + + // Port-specific should contain port number + expect(str($portUrl)->contains('_3000'))->toBeTrue(); + expect(str($baseUrl)->contains('_3000'))->toBeFalse(); +}); + +it('verifies multiple port variables for same service', function () { + // Test that a service can have multiple port-specific variables + $service = 'api'; + $ports = ['3000', '8080', '9090']; + + foreach ($ports as $port) { + $varName = "SERVICE_URL_API_$port"; + + // Should have 3 underscores + expect(substr_count($varName, '_'))->toBe(3); + + // Should extract correct service name + $serviceName = str($varName)->after('SERVICE_URL_')->beforeLast('_')->lower()->value(); + expect($serviceName)->toBe('api'); + + // Should extract correct port + $extractedPort = str($varName)->afterLast('_')->value(); + expect($extractedPort)->toBe($port); + } +}); + +it('verifies common port numbers are handled correctly', function () { + // Test common port numbers used in applications + $commonPorts = [ + '80' => 'HTTP', + '443' => 'HTTPS', + '3000' => 'Node.js/React', + '5432' => 'PostgreSQL', + '6379' => 'Redis', + '8080' => 'Alternative HTTP', + '9000' => 'PHP-FPM', + ]; + + foreach ($commonPorts as $port => $description) { + $varName = "SERVICE_URL_APP_$port"; + + expect(substr_count($varName, '_'))->toBe(3, "Failed for $description port $port"); + + $extractedPort = str($varName)->afterLast('_')->value(); + expect($extractedPort)->toBe((string) $port, "Port extraction failed for $description"); + } +}); diff --git a/tests/Unit/ServiceRequiredPortTest.php b/tests/Unit/ServiceRequiredPortTest.php new file mode 100644 index 000000000..70bf2bca2 --- /dev/null +++ b/tests/Unit/ServiceRequiredPortTest.php @@ -0,0 +1,153 @@ + [ + 'name' => 'Supabase', + 'port' => '8000', + ], + 'umami' => [ + 'name' => 'Umami', + 'port' => '3000', + ], + ]); + + $service = Mockery::mock(Service::class)->makePartial(); + $service->name = 'supabase-xyz123'; + + // Mock the get_service_templates function to return our mock data + $service->shouldReceive('getRequiredPort')->andReturn(8000); + + expect($service->getRequiredPort())->toBe(8000); +}); + +it('returns null for service without required port', function () { + $service = Mockery::mock(Service::class)->makePartial(); + $service->name = 'cloudflared-xyz123'; + + // Mock to return null for services without port + $service->shouldReceive('getRequiredPort')->andReturn(null); + + expect($service->getRequiredPort())->toBeNull(); +}); + +it('requiresPort returns true when service has required port', function () { + $service = Mockery::mock(Service::class)->makePartial(); + $service->shouldReceive('getRequiredPort')->andReturn(8000); + $service->shouldReceive('requiresPort')->andReturnUsing(function () use ($service) { + return $service->getRequiredPort() !== null; + }); + + expect($service->requiresPort())->toBeTrue(); +}); + +it('requiresPort returns false when service has no required port', function () { + $service = Mockery::mock(Service::class)->makePartial(); + $service->shouldReceive('getRequiredPort')->andReturn(null); + $service->shouldReceive('requiresPort')->andReturnUsing(function () use ($service) { + return $service->getRequiredPort() !== null; + }); + + expect($service->requiresPort())->toBeFalse(); +}); + +it('extracts port from URL with http scheme', function () { + $url = 'http://example.com:3000'; + $port = ServiceApplication::extractPortFromUrl($url); + + expect($port)->toBe(3000); +}); + +it('extracts port from URL with https scheme', function () { + $url = 'https://example.com:8080'; + $port = ServiceApplication::extractPortFromUrl($url); + + expect($port)->toBe(8080); +}); + +it('extracts port from URL without scheme', function () { + $url = 'example.com:5000'; + $port = ServiceApplication::extractPortFromUrl($url); + + expect($port)->toBe(5000); +}); + +it('returns null for URL without port', function () { + $url = 'http://example.com'; + $port = ServiceApplication::extractPortFromUrl($url); + + expect($port)->toBeNull(); +}); + +it('returns null for URL without port and without scheme', function () { + $url = 'example.com'; + $port = ServiceApplication::extractPortFromUrl($url); + + expect($port)->toBeNull(); +}); + +it('handles invalid URLs gracefully', function () { + $url = 'not-a-valid-url:::'; + $port = ServiceApplication::extractPortFromUrl($url); + + expect($port)->toBeNull(); +}); + +it('checks if all FQDNs have port - single FQDN with port', function () { + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->fqdn = 'http://example.com:3000'; + + $result = $app->allFqdnsHavePort(); + + expect($result)->toBeTrue(); +}); + +it('checks if all FQDNs have port - single FQDN without port', function () { + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->fqdn = 'http://example.com'; + + $result = $app->allFqdnsHavePort(); + + expect($result)->toBeFalse(); +}); + +it('checks if all FQDNs have port - multiple FQDNs all with ports', function () { + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->fqdn = 'http://example.com:3000,https://example.org:8080'; + + $result = $app->allFqdnsHavePort(); + + expect($result)->toBeTrue(); +}); + +it('checks if all FQDNs have port - multiple FQDNs one without port', function () { + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->fqdn = 'http://example.com:3000,https://example.org'; + + $result = $app->allFqdnsHavePort(); + + expect($result)->toBeFalse(); +}); + +it('checks if all FQDNs have port - empty FQDN', function () { + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->fqdn = ''; + + $result = $app->allFqdnsHavePort(); + + expect($result)->toBeFalse(); +}); + +it('checks if all FQDNs have port - null FQDN', function () { + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->fqdn = null; + + $result = $app->allFqdnsHavePort(); + + expect($result)->toBeFalse(); +});