From bcd225bd22d494f7160185afe7ac2aa5eeb7af77 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 6 Nov 2025 14:30:39 +0100
Subject: [PATCH] feat: Implement required port validation for service
applications
- Added `requiredPort` property to `ServiceApplicationView` to track the required port for services.
- Introduced modal confirmation for removing required ports, including methods to confirm or cancel the action.
- Enhanced `Service` model with `getRequiredPort` and `requiresPort` methods to retrieve port information from service templates.
- Implemented `extractPortFromUrl` method in `ServiceApplication` to extract port from FQDN URLs.
- Updated frontend views to display warnings when required ports are missing from domains.
- Created unit tests for service port validation and extraction logic, ensuring correct behavior for various scenarios.
- Added feature tests for Livewire component handling of domain submissions with required ports.
---
app/Livewire/Project/Service/EditDomain.php | 55 ++++++
.../Service/ServiceApplicationView.php | 55 ++++++
app/Models/Service.php | 25 +++
app/Models/ServiceApplication.php | 47 +++++
bootstrap/helpers/parsers.php | 39 ++--
.../project/service/edit-domain.blade.php | 67 ++++++-
.../service-application-view.blade.php | 71 ++++++-
.../Feature/DatabaseBackupCreationApiTest.php | 2 -
.../Service/EditDomainPortValidationTest.php | 154 ++++++++++++++++
...ckerComposeEmptyStringPreservationTest.php | 128 ++++++++++++-
tests/Unit/Policies/PrivateKeyPolicyTest.php | 1 -
.../Unit/ServicePortSpecificVariablesTest.php | 174 ++++++++++++++++++
tests/Unit/ServiceRequiredPortTest.php | 153 +++++++++++++++
13 files changed, 938 insertions(+), 33 deletions(-)
create mode 100644 tests/Feature/Service/EditDomainPortValidationTest.php
create mode 100644 tests/Unit/ServicePortSpecificVariablesTest.php
create mode 100644 tests/Unit/ServiceRequiredPortTest.php
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 @@
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 @@
-
+
+ @if ($showPortWarningModal)
+
+
+
+
+
+
+
Remove Required Port?
+
+
+
+
+
+
+
+
+ This service requires port {{ $requiredPort }} to function correctly.
+ One or more of your domains are missing a port number.
+
+
+
+
+ The service may become unreachable
+ The proxy may not be able to route traffic correctly
+ Environment variables may not be generated properly
+ The service may fail to start or function
+
+
+
+
+
+ Cancel - Keep Port
+
+
+ I understand, remove port anyway
+
+
+
+
+
+
+
+ @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();
+});