feat(domains): support per-domain internal port overrides (#11594)

This commit is contained in:
Andras Bacsai
2026-09-02 19:39:19 +02:00
committed by GitHub
parent 7a7564e6b3
commit e2e91fbb85
70 changed files with 4025 additions and 297 deletions
@@ -138,6 +138,33 @@ test('proxy settings regenerate managed labels', function () {
expect(base64_decode($this->application->fresh()->custom_labels))->not->toContain('sentinel-label=true');
});
test('changing a domain port regenerates managed labels with the requested port', function () {
$this->application->settings->update(['is_container_label_readonly_enabled' => true]);
$this->application->update([
'fqdn' => 'https://app.example.com',
'ports_exposes' => '80',
'domain_port_overrides' => [
'https://app.example.com' => 3000,
],
]);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'domains' => 'https://app.example.com:8080',
])
->assertOk();
$application = $this->application->fresh();
$labels = $application->parseContainerLabels();
expect($application->fqdn)->toBe('https://app.example.com')
->and($application->domain_port_overrides)->toBe([
'https://app.example.com' => 8080,
])
->and($labels)->toContain('loadbalancer.server.port=8080')
->and($labels)->not->toContain('loadbalancer.server.port=3000');
});
test('http basic auth updates regenerate managed labels', function () {
$this->application->settings->update(['is_container_label_readonly_enabled' => true]);
$this->application->update([
@@ -279,6 +306,34 @@ test('PATCH /api/v1/applications/{uuid} updates preview_url_template and max_res
->and($application->max_restart_count)->toBe(5);
});
test('PATCH /api/v1/applications/{uuid} clears ports_exposes with null or an empty string', function (mixed $portsExposes) {
$this->application->update(['ports_exposes' => '3000,8080']);
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'ports_exposes' => $portsExposes,
])
->assertOk();
expect($this->application->fresh()->ports_exposes)->toBeNull();
})->with([
'null' => null,
'empty string' => '',
]);
test('PATCH /api/v1/applications/{uuid} rejects invalid exposed ports', function (string $portsExposes) {
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'ports_exposes' => $portsExposes,
])
->assertUnprocessable()
->assertJsonValidationErrors('ports_exposes');
})->with([
'not numeric' => '80,abc',
'zero' => '0',
'above TCP range' => '65536',
]);
test('GET /api/v1/applications/{uuid} includes advanced settings', function () {
$this->application->settings->update(advancedApplicationSettingsPayload());
$this->application->update([
@@ -77,6 +77,47 @@ it('reports noindex domain changes as requiring a redeploy', function () {
->and($change['impact'])->toBe('redeploy');
});
it('reports domain port-only changes as requiring a redeploy', function () {
$application = configurationChangedTestApplication([
'fqdn' => 'https://app.example.com',
'domain_port_overrides' => ['https://app.example.com' => 3000],
]);
$deployment = configurationChangedDeployment($application);
$application->markDeploymentConfigurationApplied($deployment);
$application->update([
'domain_port_overrides' => ['https://app.example.com' => 8080],
]);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
$change = collect($diff->changes())->firstWhere('key', 'domains.domain_port_overrides');
expect($diff->isChanged())->toBeTrue()
->and($change)->not->toBeNull()
->and($change['impact'])->toBe('redeploy');
});
it('keeps application deployment snapshots stable when port overrides are reordered', function () {
$application = configurationChangedTestApplication([
'fqdn' => 'https://one.example.com,https://two.example.com',
'domain_port_overrides' => [
'https://one.example.com' => 3000,
'https://two.example.com' => 8080,
],
]);
$deployment = configurationChangedDeployment($application);
$application->markDeploymentConfigurationApplied($deployment);
$application->update([
'domain_port_overrides' => [
'https://two.example.com' => 8080,
'https://one.example.com' => 3000,
],
]);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
});
it('does not flag applications whose older snapshot omitted noindex domains', function () {
$application = configurationChangedTestApplication([
'fqdn' => 'https://app.example.com',
+580 -7
View File
@@ -24,6 +24,7 @@ uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
config(['app.maintenance.driver' => 'file']);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
@@ -744,9 +745,14 @@ it('composes the complete port on the server without duplicating an existing www
->assertHasNoErrors()
->assertDispatched('success');
expect(explode(',', (string) $this->application->fresh()->fqdn))->toBe([
'https://www.example.com:3000',
'https://example.com:3000',
$application = $this->application->fresh();
expect(explode(',', (string) $application->fqdn))->toBe([
'https://www.example.com',
'https://example.com',
])->and($application->domain_port_overrides)->toBe([
'https://www.example.com' => 3000,
'https://example.com' => 3000,
]);
});
@@ -821,7 +827,8 @@ it('updates a domain in place via modal', function () {
->assertSet('editingDomain', 'https://old.example.com')
->assertSee('Direction')
->assertSee('Search engine indexing')
->set('editingDomain', 'https://new.example.com')
->set('editingDomainParts.scheme', 'https')
->set('editingDomainParts.host', 'new.example.com')
->call('updateDomain')
->assertHasNoErrors()
->assertSet('showEditDomainModal', false)
@@ -845,7 +852,8 @@ it('blocks editing a domain with bad dns until the user continues', function ()
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->set('editingDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid')
->set('editingDomainParts.scheme', 'https')
->set('editingDomainParts.host', 'this-domain-should-not-resolve-for-coolify-tests.invalid')
->call('updateDomain')
->assertSet('editDomainDnsFailed', true)
->assertSet('showEditDomainModal', true)
@@ -1693,7 +1701,10 @@ it('saves after confirming a domain conflict on add', function () {
->assertSet('pendingAction', null)
->assertDispatched('success');
expect($this->application->fresh()->fqdn)->toBe('https://shared.example.com');
expect(explode(',', (string) $this->application->fresh()->fqdn))->toBe([
'https://shared.example.com',
'https://www.shared.example.com',
]);
});
it('saves after confirming a domain conflict on edit', function () {
@@ -1711,7 +1722,8 @@ it('saves after confirming a domain conflict on edit', function () {
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 0)
->set('editingDomain', 'https://taken.example.com')
->set('editingDomainParts.scheme', 'https')
->set('editingDomainParts.host', 'taken.example.com')
->call('updateDomain')
->assertSet('showDomainConflictModal', true)
->assertSet('pendingAction', 'update')
@@ -1966,6 +1978,72 @@ it('auto-adds missing www pair for a single compose service redirect', function
->and($webDomains)->toContain('https://www.web.example.com');
});
it('saves domain port overrides separately from the public FQDN', function () {
$this->application->update([
'fqdn' => 'https://one.example.com:3000,https://two.example.com:8080',
]);
$this->application->refresh();
expect($this->application->fqdn)
->toBe('https://one.example.com,https://two.example.com')
->and($this->application->domain_port_overrides)
->toBe([
'https://one.example.com' => 3000,
'https://two.example.com' => 8080,
]);
});
it('retains an existing domain port override when saving a portless domain', function () {
$this->application->update([
'fqdn' => 'https://one.example.com:3000',
]);
$this->application->update([
'fqdn' => 'https://one.example.com',
]);
expect($this->application->fresh()->fqdn)
->toBe('https://one.example.com')
->and($this->application->fresh()->domain_port_overrides)
->toBe(['https://one.example.com' => 3000]);
});
it('prunes a domain port override when that domain is removed', function () {
$this->application->update([
'fqdn' => 'https://one.example.com:3000,https://two.example.com:8080',
]);
$this->application->update([
'fqdn' => 'https://two.example.com',
]);
expect($this->application->fresh()->fqdn)
->toBe('https://two.example.com')
->and($this->application->fresh()->domain_port_overrides)
->toBe(['https://two.example.com' => 8080]);
});
it('keeps a legacy port-bearing application domain after refresh and reparse', function () {
$this->application->update([
'fqdn' => 'https://legacy.example.com',
]);
DB::table('applications')->where('id', $this->application->id)->update([
'fqdn' => 'https://legacy.example.com:9090',
]);
$application = Application::find($this->application->id);
$application->refresh();
expect($application->fqdn)->toBe('https://legacy.example.com:9090');
applicationParser($application);
$application->update(['description' => 'unrelated reparse']);
expect($application->fresh()->fqdn)->toBe('https://legacy.example.com:9090');
});
it('updates search engine indexing from the domains view', function () {
$this->application->update(['fqdn' => 'https://app.example.com,https://staging.example.com']);
@@ -1987,3 +2065,498 @@ it('updates search engine indexing from the domains view', function () {
expect($this->application->refresh()->noindexDomains()->all())
->toBe(['https://staging.example.com']);
});
it('keeps noindex domains when normalizing a custom domain port', function () {
$this->application->update([
'fqdn' => 'https://staging.example.com:8080',
'noindex_domains' => ['https://staging.example.com:8080'],
]);
expect($this->application->refresh())
->fqdn->toBe('https://staging.example.com')
->noindex_domains->toBe(['https://staging.example.com'])
->and($this->application->domain_port_overrides)
->toBe(['https://staging.example.com' => 8080]);
});
it('saves a port override from the segmented add-domain form', function () {
$this->application->update(['ports_exposes' => '3000,8080']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomainParts.host', 'example.com')
->set('newDomainParts.port', '8080')
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('success')
->assertSee('Internal port 8080')
->assertDontSee('https://example.com:8080');
$this->application->refresh();
expect(explode(',', (string) $this->application->fqdn))
->toContain('https://example.com')
->not->toContain('https://example.com:8080')
->and($this->application->domain_port_overrides['https://example.com'] ?? null)
->toBe(8080);
});
it('rejects adding a domain whose portless URL is already configured', function () {
$this->application->update([
'fqdn' => 'https://example.com:3000',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomainParts.host', 'example.com')
->set('newDomainParts.port', '8080')
->call('addDomain')
->assertHasErrors('newDomain');
expect($this->application->fresh()->fqdn)->toBe('https://example.com')
->and($this->application->fresh()->domain_port_overrides)
->toBe(['https://example.com' => 3000]);
});
it('rejects renaming a domain to a port variant of another configured domain', function () {
$this->application->update([
'fqdn' => 'https://first.example.com:3000,https://second.example.com:4000',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 1)
->set('editingDomainParts.host', 'first.example.com')
->set('editingDomainParts.port', '8080')
->call('updateDomain')
->assertHasErrors('editingDomain');
expect($this->application->fresh()->fqdn)
->toBe('https://first.example.com,https://second.example.com')
->and($this->application->fresh()->domain_port_overrides)
->toBe([
'https://first.example.com' => 3000,
'https://second.example.com' => 4000,
]);
});
it('composes segmented add-domain fields into a port override when the changed flag is false', function () {
$this->application->update(['ports_exposes' => '3000,8080']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomainParts.host', 'example.com')
->set('newDomainParts.port', '8080')
->set('newDomainPartsChanged', false)
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('success');
expect($this->application->fresh()->fqdn)
->toContain('https://example.com')
->not->toContain(':8080')
->and($this->application->fresh()->domain_port_overrides)
->toHaveKey('https://example.com', 8080);
});
it('retains different port overrides for two application domains', function () {
$this->application->update(['ports_exposes' => '3000,8080']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomainParts.host', 'one.example.com')
->set('newDomainParts.port', '3000')
->call('addDomain')
->assertHasNoErrors()
->set('newDomainParts.host', 'two.example.com')
->set('newDomainParts.port', '8080')
->call('addDomain')
->assertHasNoErrors();
$this->application->refresh();
expect($this->application->domain_port_overrides['https://one.example.com'] ?? null)->toBe(3000)
->and($this->application->domain_port_overrides['https://two.example.com'] ?? null)->toBe(8080)
->and(explode(',', (string) $this->application->fqdn))
->toContain('https://one.example.com')
->toContain('https://two.example.com')
->not->toContain('https://one.example.com:3000')
->not->toContain('https://two.example.com:8080');
});
it('reopens edit with the saved domain port override', function () {
$this->application->update([
'ports_exposes' => '3000,8080',
'fqdn' => 'https://example.com:8080',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.url', 'https://example.com')
->assertSet('domainRows.0.internal_port', 8080)
->assertSet('domainRows.0.has_port_override', true)
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '8080')
->assertSet('editingDomainParts.host', 'example.com');
});
it('does not prefill the default internal port when editing a domain without a port override', function () {
$this->application->update([
'ports_exposes' => '3000,8080',
'fqdn' => 'https://example.com',
'domain_port_overrides' => null,
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.internal_port', 3000)
->assertSet('domainRows.0.has_port_override', false)
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '');
});
it('clears a domain port override and shows the default internal port', function () {
$this->application->update([
'ports_exposes' => '3000,8080',
'fqdn' => 'https://one.example.com:8080,https://two.example.com:9090',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSee('Internal port 8080')
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '8080')
->set('editingDomainParts.port', '')
->call('updateDomain')
->assertHasNoErrors()
->assertSee('Internal port 3000')
->assertSee('Internal port 9090')
->assertDontSee('Internal port 8080');
$this->application->refresh();
expect($this->application->fqdn)
->toContain('https://one.example.com')
->and($this->application->domain_port_overrides)
->not->toHaveKey('https://one.example.com')
->toHaveKey('https://two.example.com', 9090);
});
it('prunes a domain port override when removing the domain from the ui', function () {
$this->application->update([
'ports_exposes' => '3000,8080',
'fqdn' => 'https://one.example.com:8080,https://two.example.com:3000',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('removeDomain', 0)
->assertDispatched('success');
$this->application->refresh();
expect($this->application->fqdn)->toBe('https://two.example.com')
->and($this->application->domain_port_overrides)
->toBe(['https://two.example.com' => 3000]);
});
it('renders a portless domain link with an internal port badge for overrides', function () {
$this->application->update([
'ports_exposes' => '3000,8080',
'fqdn' => 'https://example.com:8080',
]);
$html = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSee('Internal port 8080')
->assertSee('https://example.com')
->assertDontSee('https://example.com:8080')
->html();
expect($html)
->toContain('href="'.getFqdnWithoutPort('https://example.com').'"')
->not->toContain('href="https://example.com:8080"')
->toContain('Custom internal port for this domain')
->not->toContain('Inherited from Ports Exposes');
});
it('shows an error badge when a domain has no internal port and ports exposes is empty', function () {
$this->application->update([
'ports_exposes' => null,
'fqdn' => 'https://example.com',
'domain_port_overrides' => null,
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.internal_port', null)
->assertSee('No internal port')
->assertDontSee('Internal port ')
->assertSee('table-badge-danger', false);
});
it('keeps the internal port badge when a domain override exists without ports exposes', function () {
$this->application->update([
'ports_exposes' => null,
'fqdn' => 'https://example.com',
'domain_port_overrides' => [
'https://example.com' => 8080,
],
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSee('Internal port 8080')
->assertDontSee('No internal port');
});
it('distinguishes an inherited internal port from a domain port override', function () {
$this->application->update([
'ports_exposes' => '3000,8080',
'fqdn' => 'https://example.com',
'domain_port_overrides' => null,
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSee('Internal port 3000')
->assertSee('Inherited from Ports Exposes', false)
->assertDontSee('Custom internal port for this domain', false);
});
it('keeps a legacy port-bearing url port in the edit field as an internal port override', function () {
$this->application->update([
'ports_exposes' => '3000,8080',
'fqdn' => 'https://legacy.example.com',
]);
DB::table('applications')->where('id', $this->application->id)->update([
'fqdn' => 'https://legacy.example.com:9090',
'domain_port_overrides' => null,
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.url', 'https://legacy.example.com:9090')
->assertSet('domainRows.0.internal_port', 9090)
->assertSet('domainRows.0.has_port_override', true)
->assertSee('Internal port 9090')
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '9090');
});
it('stores compose domain port overrides without wiping other services', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'fqdn' => null,
'ports_exposes' => '3000,8080',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: node:alpine\n",
'docker_compose_domains' => json_encode([
'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'],
]),
'domain_port_overrides' => [
'https://api.example.com' => 4000,
],
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomainService', 'web')
->set('newDomainParts.host', 'web.example.com')
->set('newDomainParts.port', '8080')
->set('newDomainPartsChanged', false)
->call('addDomain')
->assertHasNoErrors()
->assertSee('Internal port 8080');
$this->application->refresh();
$domains = json_decode($this->application->docker_compose_domains, true);
expect(data_get($domains, 'web.domain'))
->toContain('https://web.example.com')
->not->toContain(':8080')
->and($this->application->fqdn)->toBeNull()
->and($this->application->domain_port_overrides)
->toHaveKey('https://web.example.com', 8080)
->toHaveKey('https://api.example.com', 4000);
});
it('prunes a compose domain port override when that domain is removed', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'fqdn' => null,
'ports_exposes' => '3000,8080',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: node:alpine\n",
'docker_compose_domains' => json_encode([
'web' => ['domain' => 'https://web.example.com', 'redirect' => 'both'],
'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'],
]),
'domain_port_overrides' => [
'https://web.example.com' => 8080,
'https://api.example.com' => 4000,
],
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('removeDomain', 0)
->assertDispatched('success');
$this->application->refresh();
expect($this->application->domain_port_overrides)
->not->toHaveKey('https://web.example.com')
->toHaveKey('https://api.example.com', 4000)
->and($this->application->fqdn)->toBeNull();
});
function applicationDomainPortOverrideApiToken(User $user, Team $team): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'application-domain-port-override-api',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $team->id,
]);
auth()->logout();
return $token->getKey().'|'.$plainTextToken;
}
it('application domain port override API update containing a port persists a portless FQDN and override', function () {
$bearer = applicationDomainPortOverrideApiToken($this->user, $this->team);
$this->withToken($bearer)
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'domains' => 'https://example.com:8080',
])
->assertOk();
$application = $this->application->fresh();
expect($application->fqdn)->toBe('https://example.com')
->and($application->domain_port_overrides)
->toBe(['https://example.com' => 8080]);
});
it('application domain port override API update omitting ports preserves overrides for unchanged domains', function () {
$this->application->update([
'fqdn' => 'https://one.example.com:3000,https://two.example.com:8080',
]);
$bearer = applicationDomainPortOverrideApiToken($this->user, $this->team);
$this->withToken($bearer)
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'domains' => 'https://one.example.com,https://two.example.com',
])
->assertOk();
$application = $this->application->fresh();
expect($application->fqdn)->toBe('https://one.example.com,https://two.example.com')
->and($application->domain_port_overrides)
->toBe([
'https://one.example.com' => 3000,
'https://two.example.com' => 8080,
]);
});
it('application domain port override API domain removal prunes the override', function () {
$this->application->update([
'fqdn' => 'https://one.example.com:3000,https://two.example.com:8080',
]);
$bearer = applicationDomainPortOverrideApiToken($this->user, $this->team);
$this->withToken($bearer)
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'domains' => 'https://two.example.com',
])
->assertOk();
$application = $this->application->fresh();
expect($application->fqdn)->toBe('https://two.example.com')
->and($application->domain_port_overrides)
->toBe(['https://two.example.com' => 8080]);
});
it('application domain port override API update of an unrelated field does not rewrite a legacy FQDN', function () {
$this->application->update([
'fqdn' => 'https://legacy.example.com',
'description' => 'before',
]);
DB::table('applications')->where('id', $this->application->id)->update([
'fqdn' => 'https://legacy.example.com:9090',
]);
$bearer = applicationDomainPortOverrideApiToken($this->user, $this->team);
$this->withToken($bearer)
->getJson("/api/v1/applications/{$this->application->uuid}")
->assertOk();
expect($this->application->fresh()->fqdn)->toBe('https://legacy.example.com:9090');
$this->withToken($bearer)
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'description' => 'unrelated',
])
->assertOk();
$application = $this->application->fresh();
expect($application->fqdn)->toBe('https://legacy.example.com:9090')
->and($application->description)->toBe('unrelated')
->and($application->domain_port_overrides)->toBeNull();
});
it('treats ports exposes and existing domain ports as available internal ports', function () {
$this->application->update([
'ports_exposes' => '3000,8080',
'fqdn' => 'https://one.example.com:9090',
]);
$application = $this->application->fresh();
expect($application->availableInternalPorts())->toBe([3000, 8080, 9090])
->and($application->portRequiresConfirmation(3000))->toBeFalse()
->and($application->portRequiresConfirmation(8080))->toBeFalse()
->and($application->portRequiresConfirmation(9090))->toBeFalse()
->and($application->portRequiresConfirmation(5555))->toBeTrue()
->and($application->portRequiresConfirmation(null))->toBeFalse();
});
it('shows a port warning when an application domain uses a port that is not exposed or already used', function () {
$this->application->update(['ports_exposes' => '3000,8080']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomainParts.host', 'example.com')
->set('newDomainParts.port', '5555')
->call('addDomain')
->assertSet('showPortWarningModal', true)
->assertSet('unrecognizedPort', 5555)
->assertSee('Use a different port?');
expect($this->application->fresh()->fqdn)->toBeNull();
});
it('saves an unrecognized application domain port after confirming the warning', function () {
$this->application->update(['ports_exposes' => '3000,8080']);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->set('newDomainParts.host', 'example.com')
->set('newDomainParts.port', '5555')
->call('addDomain')
->assertSet('showPortWarningModal', true)
->call('confirmUseUnknownPort')
->assertSet('showPortWarningModal', false)
->assertDispatched('success');
$application = $this->application->fresh();
expect(explode(',', (string) $application->fqdn))
->toContain('https://example.com')
->and($application->domain_port_overrides['https://example.com'] ?? null)->toBe(5555);
});
it('does not warn when editing an application domain to a port already used by another domain', function () {
$this->application->update([
'ports_exposes' => '3000',
'fqdn' => 'https://one.example.com:9090,https://two.example.com',
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->call('startEdit', 1)
->set('editingDomainParts.port', '9090')
->call('updateDomain')
->assertSet('showPortWarningModal', false)
->assertDispatched('success');
});
@@ -120,3 +120,32 @@ test('switching from railpack to compose preserves the existing application doma
expect($application->refresh()->fqdn)
->toBe('https://example.com,https://www.example.com');
});
test('networking section hints that internal ports can be set per domain', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'ports_exposes' => '3000,3001',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
$domainsUrl = route('project.application.domains', [
'project_uuid' => $application->environment->project->uuid,
'environment_uuid' => $application->environment->uuid,
'application_uuid' => $application->uuid,
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSeeInOrder([
'Ports exposes',
'You can also set an internal port per domain on',
'Port mappings',
])
->assertSee($domainsUrl, false);
});
@@ -10,10 +10,20 @@ use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use phpseclib3\Crypt\EC;
uses(RefreshDatabase::class);
function disableExactProxyLabels(Application $application): Application
{
$settings = $application->destination->server->settings;
$settings->generate_exact_labels = false;
$settings->save();
return $application;
}
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
@@ -454,3 +464,138 @@ YAML,
->toContain('traefik.docker.network=custom-network')
->not->toContain("traefik.docker.network={$application->uuid}");
});
test('generateLabelsApplication routes portless domains to saved internal port overrides for Traefik and Caddy', function () {
$application = disableExactProxyLabels(Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'ports_exposes' => '80',
'fqdn' => 'https://one.example.com,https://two.example.com',
'domain_port_overrides' => [
'https://one.example.com' => 3000,
'https://two.example.com' => 8080,
],
'redirect' => 'both',
'is_http_basic_auth_enabled' => false,
]));
$labels = collect(generateLabelsApplication($application));
expect($labels)
->toContain('traefik.http.routers.https-0-'.$application->uuid.'.rule=Host(`one.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-0-'.$application->uuid.'.loadbalancer.server.port=3000')
->toContain('traefik.http.routers.https-1-'.$application->uuid.'.rule=Host(`two.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-1-'.$application->uuid.'.loadbalancer.server.port=8080')
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}')
->toContain('caddy_1.handle_path.1_reverse_proxy={{upstreams 8080}}')
->not->toContain('Host(`one.example.com:3000`)')
->not->toContain('Host(`two.example.com:8080`)');
});
test('generateLabelsApplication uses the first ports_exposes value when a portless domain has no override', function () {
$application = disableExactProxyLabels(Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'ports_exposes' => '4000,5000',
'fqdn' => 'https://plain.example.com',
'domain_port_overrides' => null,
'redirect' => 'both',
'is_http_basic_auth_enabled' => false,
]));
$labels = collect(generateLabelsApplication($application));
expect($labels)
->toContain('traefik.http.services.https-0-'.$application->uuid.'.loadbalancer.server.port=4000')
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 4000}}');
});
test('generateLabelsApplication keeps routing a legacy port-bearing FQDN without an override map', function () {
$application = disableExactProxyLabels(Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'ports_exposes' => '80',
'fqdn' => 'https://legacy.example.com',
'redirect' => 'both',
'is_http_basic_auth_enabled' => false,
]));
DB::table('applications')->where('id', $application->id)->update([
'fqdn' => 'https://legacy.example.com:9090',
'domain_port_overrides' => null,
]);
$labels = collect(generateLabelsApplication($application->fresh()));
expect($labels)
->toContain('traefik.http.routers.https-0-'.$application->uuid.'.rule=Host(`legacy.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-0-'.$application->uuid.'.loadbalancer.server.port=9090')
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 9090}}');
});
test('applicationParser compose labels receive the application domain port override map', function () {
$application = disableExactProxyLabels(Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'docker_compose_raw' => <<<'YAML'
services:
frontend:
image: myapp/frontend:latest
YAML,
'fqdn' => null,
'docker_compose_domains' => json_encode([
'frontend' => ['domain' => 'https://frontend.example.com'],
]),
]));
$application->update([
'domain_port_overrides' => [
'https://frontend.example.com' => 8080,
],
]);
$parsedCompose = applicationParser($application->fresh());
$labels = collect(data_get($parsedCompose, 'services.frontend.labels'));
expect($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=8080')))
->toBeTrue()
->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams 8080}}')))
->toBeTrue()
->and($labels->contains(fn (string $label): bool => str_contains($label, 'Host(`frontend.example.com`)')))
->toBeTrue();
});
test('applicationParser compose labels use the first ports_exposes value when a portless domain has no override', function () {
$application = disableExactProxyLabels(Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'ports_exposes' => '3000,8080',
'docker_compose_raw' => <<<'YAML'
services:
frontend:
image: myapp/frontend:latest
YAML,
'fqdn' => null,
'domain_port_overrides' => null,
'docker_compose_domains' => json_encode([
'frontend' => ['domain' => 'https://frontend.example.com'],
]),
]));
$parsedCompose = applicationParser($application->fresh());
$labels = collect(data_get($parsedCompose, 'services.frontend.labels'));
expect($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=3000')))
->toBeTrue()
->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams 3000}}')))
->toBeTrue()
->and($labels->contains(fn (string $label): bool => str_contains($label, 'Host(`frontend.example.com`)')))
->toBeTrue();
});
+272
View File
@@ -12,6 +12,7 @@ use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
@@ -19,6 +20,7 @@ uses(RefreshDatabase::class);
beforeEach(function () {
Bus::fake();
config()->set('app.maintenance.store', 'array');
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
@@ -130,3 +132,273 @@ describe('DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}', functi
$response->assertForbidden();
});
});
describe('PATCH /api/v1/applications/{uuid}/previews/{pull_request_id}', function () {
test('stores preview domain ports separately from portless public domains', function () {
$preview = createPreview($this->application, 42);
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/42", [
'domains' => 'https://one.example.com:3000,https://two.example.com:8080',
])
->assertOk()
->assertJsonPath('domains', 'https://one.example.com,https://two.example.com');
expect($response->json('domain_port_overrides'))->toBe([
'https://one.example.com' => 3000,
'https://two.example.com' => 8080,
]);
expect($preview->fresh()->fqdn)->toBe('https://one.example.com,https://two.example.com')
->and($preview->fresh()->domain_port_overrides)->toBe([
'https://one.example.com' => 3000,
'https://two.example.com' => 8080,
]);
});
test('clears an existing preview domain override when the submitted domain is portless', function () {
$preview = createPreview($this->application, 43);
$preview->update(['fqdn' => 'https://preview.example.com:8080']);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/43", [
'domains' => 'https://preview.example.com',
])
->assertOk()
->assertJsonPath('domain_port_overrides', null);
expect($preview->fresh()->fqdn)->toBe('https://preview.example.com')
->and($preview->fresh()->domain_port_overrides)->toBeNull();
});
test('rejects invalid preview domains', function () {
createPreview($this->application, 44);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/44", [
'domains' => 'not-a-domain',
])
->assertUnprocessable()
->assertJsonValidationErrors('domains');
});
test('rejects preview domain ports outside the valid TCP range', function (string $domain) {
createPreview($this->application, 59);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/59", [
'domains' => $domain,
])
->assertUnprocessable()
->assertJsonValidationErrors('domains');
})->with([
'zero' => 'https://preview.example.com:0',
'above maximum' => 'https://preview.example.com:65536',
]);
test('returns 403 when token lacks write ability', function () {
$readOnlyToken = createTeamApiToken($this->user, $this->team, ['read']);
createPreview($this->application, 45);
$this->withHeaders(previewAuthHeaders($readOnlyToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/45", [
'domains' => 'https://preview.example.com:3000',
])
->assertForbidden();
});
test('rejects a non-integer pull request id', function () {
createPreview($this->application, 1);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/1.9", [
'domains' => 'https://preview.example.com:3000',
])
->assertUnprocessable()
->assertJson(['message' => 'Invalid pull_request_id.']);
});
test('detects conflicts after removing the submitted internal port', function () {
$otherApplication = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => 'https://taken.example.com',
]);
createPreview($this->application, 46);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/46", [
'domains' => 'https://taken.example.com:3000',
])
->assertConflict()
->assertJsonPath('conflicts.0.resource_uuid', $otherApplication->uuid);
});
test('detects conflicts with another preview domain', function () {
createPreview($this->application, 47)->update(['fqdn' => 'https://taken-preview.example.com:3000']);
createPreview($this->application, 48);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/48", [
'domains' => 'https://taken-preview.example.com:8080',
])
->assertConflict();
});
test('filters preview conflict candidates in the database', function () {
createPreview($this->application, 56)->update(['fqdn' => 'https://taken-preview.example.com']);
createPreview($this->application, 57)->update(['fqdn' => 'https://current-preview.example.com']);
createPreview($this->application, 58)->update(['fqdn' => 'https://unrelated.example.com']);
$queries = collect();
DB::listen(function ($query) use ($queries): void {
if (str_contains($query->sql, 'from "application_previews"')) {
$queries->push($query->sql);
}
});
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/57", [
'domains' => 'https://taken-preview.example.com',
])
->assertConflict();
expect($queries->first(fn (string $sql): bool => str_contains($sql, '"application_id" in (select')
&& str_contains($sql, '"fqdn" is not null')
&& str_contains($sql, '"fqdn" like ?')))->not->toBeNull();
});
test('updates Docker Compose preview domains with per-domain ports', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n",
]);
$preview = createPreview($this->application, 49);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/49", [
'docker_compose_domains' => [
['name' => 'web', 'domain' => 'https://web-preview.example.com:8080'],
['name' => 'api', 'domain' => 'https://api-preview.example.com:3000'],
],
])
->assertOk()
->assertJsonPath('docker_compose_domains.0.name', 'web')
->assertJsonPath('docker_compose_domains.0.domain', 'https://web-preview.example.com')
->assertJsonPath('docker_compose_domains.1.name', 'api')
->assertJsonPath('docker_compose_domains.1.domain', 'https://api-preview.example.com');
$preview->refresh();
expect(json_decode($preview->docker_compose_domains, true))->toBe([
'web' => ['domain' => 'https://web-preview.example.com'],
'api' => ['domain' => 'https://api-preview.example.com'],
])->and($preview->fqdn)->toBe('https://web-preview.example.com,https://api-preview.example.com')
->and($preview->domain_port_overrides)->toBe([
'https://web-preview.example.com' => 8080,
'https://api-preview.example.com' => 3000,
]);
});
test('clears Docker Compose preview port overrides with portless domains', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
]);
$preview = createPreview($this->application, 50);
$preview->update([
'fqdn' => 'https://web-preview.example.com:8080',
'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://web-preview.example.com']]),
]);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/50", [
'docker_compose_domains' => [
['name' => 'web', 'domain' => 'https://web-preview.example.com'],
],
])
->assertOk()
->assertJsonPath('domain_port_overrides', null);
expect($preview->fresh()->domain_port_overrides)->toBeNull();
});
test('rejects unknown Docker Compose preview services', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
]);
createPreview($this->application, 51);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/51", [
'docker_compose_domains' => [
['name' => 'unknown', 'domain' => 'https://unknown.example.com:8080'],
],
])
->assertUnprocessable()
->assertJsonValidationErrors('docker_compose_domains');
});
test('rejects the same Docker Compose preview domain on different internal ports', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n",
]);
createPreview($this->application, 52);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/52", [
'docker_compose_domains' => [
['name' => 'web', 'domain' => 'https://duplicate.example.com:8080'],
['name' => 'api', 'domain' => 'https://duplicate.example.com:3000'],
],
])
->assertUnprocessable()
->assertJsonValidationErrors('docker_compose_domains');
});
test('rejects missing Compose services without changing the preview', function () {
$this->application->update(['build_pack' => 'dockercompose', 'docker_compose_raw' => '']);
$preview = createPreview($this->application, 53);
$originalFqdn = $preview->fresh()->fqdn;
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/53", [
'docker_compose_domains' => [],
])
->assertUnprocessable()
->assertJsonValidationErrors('docker_compose_domains');
expect($preview->fresh()->fqdn)->toBe($originalFqdn);
});
test('rejects the domain field for Compose previews', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
]);
createPreview($this->application, 54);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/54", [
'domains' => 'https://ignored.example.com',
'docker_compose_domains' => [],
])
->assertUnprocessable()
->assertJsonValidationErrors('domains');
});
test('rejects Docker Compose domains for non-Compose previews', function () {
createPreview($this->application, 55);
$this->withHeaders(previewAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}/previews/55", [
'domains' => 'https://preview.example.com',
'docker_compose_domains' => [],
])
->assertUnprocessable()
->assertJsonValidationErrors('docker_compose_domains');
});
});
@@ -83,6 +83,27 @@ it('declares update authorization on service backup mutation controls', function
->toMatch('/<x-forms\.button(?=[^>]*wire:click\.stop="backupNow\(\'storage\',[^"]+")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>/');
});
it('declares update authorization on application port controls', function () {
$domainsView = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
$previewDomainsView = file_get_contents(resource_path('views/livewire/project/application/preview-domains.blade.php'));
$generalView = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
expect($domainsView)
->toMatch('/<x-forms\.button(?=[^>]*canGate="update")(?=[^>]*:canResource="\$application")[^>]*>\s*Cancel/s')
->toMatch('/<x-forms\.button(?=[^>]*wire:click="confirmUseUnknownPort")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$application")[^>]*>/s');
expect($previewDomainsView)
->toMatch('/<x-forms\.button[^\n]*canGate="update" :canResource="\$preview->application"[\s\S]{0,150}?Cancel/')
->toMatch('/<x-forms\.button[^\n]*wire:click="confirmUseUnknownPort" canGate="update"\s+:canResource="\$preview->application"/');
$portsExposesControls = str($generalView)
->after("@if (\$isStatic || \$buildPack === 'static')")
->before('<p class="mt-1.5 text-xs');
expect($portsExposesControls->substrCount('id="portsExposes"'))->toBe(3)
->and($portsExposesControls->substrCount('canGate="update" :canResource="$application"'))->toBe(3);
});
it('keeps mutable Livewire components behind authorization checks', function (string $path, array $requiredNeedles) {
$source = file_get_contents(base_path($path));
@@ -0,0 +1,674 @@
<?php
use App\Livewire\Project\Application\PreviewDomains;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
config(['app.maintenance.driver' => 'file']);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
[
'id' => 0,
'is_dns_validation_enabled' => false,
]
));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
'ip' => '203.0.113.10',
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'generate_exact_labels' => false,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$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([
'uuid' => (string) Str::uuid(),
'name' => 'Preview Port App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => null,
'redirect' => 'both',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000,8080',
'is_http_basic_auth_enabled' => false,
]);
});
function createPreviewForPortTests(Application $application, int $pullRequestId, array $attributes = []): ApplicationPreview
{
return ApplicationPreview::create(array_merge([
'application_id' => $application->id,
'pull_request_id' => $pullRequestId,
'pull_request_html_url' => "https://github.com/coollabsio/coolify/pull/{$pullRequestId}",
], $attributes));
}
it('saves preview domain port overrides separately from the public FQDN', function () {
$preview = createPreviewForPortTests($this->application, 101);
$preview->update([
'fqdn' => 'https://one-pr-101.example.com:3000,https://two-pr-101.example.com:8080',
]);
$preview->refresh();
expect($preview->fqdn)
->toBe('https://one-pr-101.example.com,https://two-pr-101.example.com')
->and($preview->domain_port_overrides)
->toBe([
'https://one-pr-101.example.com' => 3000,
'https://two-pr-101.example.com' => 8080,
]);
});
it('retains an existing preview port override when saving a portless domain', function () {
$preview = createPreviewForPortTests($this->application, 102, [
'fqdn' => 'https://one-pr-102.example.com:3000',
]);
$preview->update([
'fqdn' => 'https://one-pr-102.example.com',
]);
expect($preview->fresh()->fqdn)
->toBe('https://one-pr-102.example.com')
->and($preview->fresh()->domain_port_overrides)
->toBe(['https://one-pr-102.example.com' => 3000]);
});
it('saves a preview port override from the add-domain form', function () {
$preview = createPreviewForPortTests($this->application, 103);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->set('newDomainParts.host', 'preview.example.com')
->set('newDomainParts.port', '8080')
->call('addDomain')
->assertHasNoErrors()
->assertDispatched('success')
->assertSee('Internal port 8080')
->assertDontSee('https://preview.example.com:8080');
$preview->refresh();
expect($preview->fqdn)
->toBe('https://preview.example.com')
->and($preview->domain_port_overrides['https://preview.example.com'] ?? null)
->toBe(8080);
});
it('retains different port overrides for two preview domains', function () {
$preview = createPreviewForPortTests($this->application, 104);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->set('newDomainParts.host', 'one-preview.example.com')
->set('newDomainParts.port', '3000')
->call('addDomain')
->assertHasNoErrors()
->set('newDomainParts.host', 'two-preview.example.com')
->set('newDomainParts.port', '8080')
->call('addDomain')
->assertHasNoErrors();
$preview->refresh();
expect($preview->domain_port_overrides['https://one-preview.example.com'] ?? null)->toBe(3000)
->and($preview->domain_port_overrides['https://two-preview.example.com'] ?? null)->toBe(8080)
->and(explode(',', (string) $preview->fqdn))
->toContain('https://one-preview.example.com')
->toContain('https://two-preview.example.com')
->not->toContain('https://one-preview.example.com:3000')
->not->toContain('https://two-preview.example.com:8080');
});
it('reopens preview domain edit with the saved port override', function () {
$preview = createPreviewForPortTests($this->application, 105, [
'fqdn' => 'https://preview.example.com:8080',
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->assertSet('domainRows.0.url', 'https://preview.example.com')
->assertSet('domainRows.0.internal_port', 8080)
->assertSet('domainRows.0.has_port_override', true)
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '8080')
->assertSet('editingDomainParts.host', 'preview.example.com');
});
it('does not prefill the default internal port when editing a preview domain without an override', function () {
$preview = createPreviewForPortTests($this->application, 106, [
'fqdn' => 'https://preview.example.com',
'domain_port_overrides' => null,
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->assertSet('domainRows.0.internal_port', 3000)
->assertSet('domainRows.0.has_port_override', false)
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '');
});
it('clears a preview domain port override and shows the default internal port', function () {
$preview = createPreviewForPortTests($this->application, 107, [
'fqdn' => 'https://one-preview.example.com:8080,https://two-preview.example.com:9090',
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->assertSee('Internal port 8080')
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '8080')
->set('editingDomainParts.port', '')
->call('updateDomain')
->assertHasNoErrors()
->assertSee('Internal port 3000')
->assertSee('Internal port 9090')
->assertDontSee('Internal port 8080');
$preview->refresh();
expect($preview->fqdn)
->toContain('https://one-preview.example.com')
->and($preview->domain_port_overrides)
->not->toHaveKey('https://one-preview.example.com')
->toHaveKey('https://two-preview.example.com', 9090);
});
it('prunes a preview domain port override when removing the domain', function () {
$preview = createPreviewForPortTests($this->application, 108, [
'fqdn' => 'https://one-preview.example.com:8080,https://two-preview.example.com:3000',
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->call('removeDomain', 0)
->assertDispatched('success');
$preview->refresh();
expect($preview->fqdn)->toBe('https://two-preview.example.com')
->and($preview->domain_port_overrides)
->toBe(['https://two-preview.example.com' => 3000]);
});
it('shows an error badge when a preview domain has no internal port and ports exposes is empty', function () {
$this->application->update([
'ports_exposes' => null,
]);
$preview = createPreviewForPortTests($this->application, 109, [
'fqdn' => 'https://preview.example.com',
'domain_port_overrides' => null,
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->assertSee('No internal port')
->assertDontSee('Internal port');
});
it('rejects adding a preview domain whose portless URL is already configured', function () {
$preview = createPreviewForPortTests($this->application, 110, [
'fqdn' => 'https://preview.example.com:3000',
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->set('newDomainParts.host', 'preview.example.com')
->set('newDomainParts.port', '8080')
->call('addDomain')
->assertHasErrors('newDomainParts.host');
expect($preview->fresh()->fqdn)->toBe('https://preview.example.com')
->and($preview->fresh()->domain_port_overrides)
->toBe(['https://preview.example.com' => 3000]);
});
it('saves compose preview domain port overrides per service without putting the port in the public URL', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n",
'docker_compose_domains' => null,
]);
$preview = createPreviewForPortTests($this->application, 111);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->set('newDomainService', 'web')
->set('newDomainParts.host', 'web-preview.example.com')
->set('newDomainParts.port', '8080')
->call('addDomain')
->assertHasNoErrors()
->set('newDomainService', 'api')
->set('newDomainParts.host', 'api-preview.example.com')
->set('newDomainParts.port', '3000')
->call('addDomain')
->assertHasNoErrors();
$preview->refresh();
$composeDomains = json_decode($preview->docker_compose_domains, true);
expect(data_get($composeDomains, 'web.domain'))->toBe('https://web-preview.example.com')
->and(data_get($composeDomains, 'api.domain'))->toBe('https://api-preview.example.com')
->and($preview->domain_port_overrides)
->toBe([
'https://web-preview.example.com' => 8080,
'https://api-preview.example.com' => 3000,
]);
});
it('copies the parent domain port override onto a generated preview domain', function () {
$this->application->update([
'fqdn' => 'https://app.example.com:8080',
]);
$preview = createPreviewForPortTests($this->application, 112);
$preview->generate_preview_fqdn();
$preview->refresh();
expect($preview->fqdn)
->toContain('112.')
->not->toContain(':8080')
->and($preview->domain_port_overrides)
->toHaveCount(1)
->and(array_values($preview->domain_port_overrides))
->toBe([8080]);
});
it('saves generated preview domains once in the application parser', function () {
$parser = file_get_contents(base_path('bootstrap/helpers/parsers.php'));
$previewGeneration = Str::of($parser)
->after('// If the domain is set, we need to generate the FQDNs for the preview')
->before('$defaultLabels = defaultLabels');
expect($previewGeneration->substrCount('$preview->save();'))->toBe(1)
->and((string) $previewGeneration)->toContain('$preview->fqdn = $fqdns->implode(\',\');');
});
it('keeps every generated preview domain port override in the legacy compose parser', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'compose_parsing_version' => '2',
'docker_compose_raw' => <<<'YAML'
services:
frontend:
image: nginx:alpine
YAML,
'docker_compose_domains' => json_encode([
'frontend' => ['domain' => 'https://one.example.com:3000,https://two.example.com:8080'],
]),
]);
$preview = createPreviewForPortTests($this->application, 124);
parseDockerComposeFile($this->application->fresh(), pull_request_id: 124, preview_id: $preview->id);
$preview->refresh();
$previewDomains = explode(',', (string) $preview->fqdn);
expect($previewDomains)->toHaveCount(2)
->and(collect($previewDomains)
->filter(fn (string $domain): bool => parse_url($domain, PHP_URL_PORT) !== null))
->toBeEmpty()
->and($preview->domain_port_overrides)->toHaveCount(2)
->and(array_keys($preview->domain_port_overrides))->toBe($previewDomains)
->and(array_values($preview->domain_port_overrides))->toBe([3000, 8080]);
});
it('finds the legacy compose preview by pull request when its id is unavailable', function (?int $previewId) {
$this->application->update([
'build_pack' => 'dockercompose',
'compose_parsing_version' => '2',
'docker_compose_raw' => "services:\n frontend:\n image: nginx:alpine\n",
'docker_compose_domains' => json_encode([
'frontend' => ['domain' => 'https://app.example.com:3000'],
]),
]);
$preview = createPreviewForPortTests($this->application, 125);
parseDockerComposeFile($this->application->fresh(), pull_request_id: 125, preview_id: $previewId);
expect($preview->fresh()->fqdn)->not->toBeNull();
})->with([
'missing id' => null,
'stale id' => PHP_INT_MAX,
]);
it('throws a controlled exception when the legacy compose preview does not exist', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'compose_parsing_version' => '2',
'docker_compose_raw' => "services:\n frontend:\n image: nginx:alpine\n",
'docker_compose_domains' => json_encode([
'frontend' => ['domain' => 'https://app.example.com:3000'],
]),
]);
expect(fn () => parseDockerComposeFile(
$this->application->fresh(),
pull_request_id: 126,
preview_id: PHP_INT_MAX,
))->toThrow(RuntimeException::class, 'Preview not found.');
});
it('preserves an existing preview port override in the legacy compose parser', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'compose_parsing_version' => '2',
'docker_compose_raw' => <<<'YAML'
services:
frontend:
image: nginx:alpine
YAML,
'docker_compose_domains' => json_encode([
'frontend' => ['domain' => 'https://frontend.example.com'],
]),
]);
$preview = createPreviewForPortTests($this->application, 126, [
'fqdn' => 'https://126.frontend.example.com',
'domain_port_overrides' => [
'https://126.frontend.example.com' => 8080,
],
]);
parseDockerComposeFile($this->application->fresh(), pull_request_id: 126, preview_id: $preview->id);
expect($preview->fresh()->domain_port_overrides)
->toBe(['https://126.frontend.example.com' => 8080]);
});
it('does not copy production domain port overrides onto preview proxy labels', function () {
$this->application->update([
'fqdn' => 'https://app.example.com',
'domain_port_overrides' => [
'https://app.example.com' => 9090,
],
]);
$preview = createPreviewForPortTests($this->application, 113, [
'fqdn' => 'https://113.app.example.com',
'domain_port_overrides' => null,
]);
$labels = collect(generateLabelsApplication($this->application->fresh(), $preview->fresh()));
expect($labels)
->toContain('traefik.http.services.https-0-'.$this->application->uuid.'-pr-113.loadbalancer.server.port=3000')
->not->toContain('loadbalancer.server.port=9090');
});
it('routes portless preview domains to saved preview internal port overrides', function () {
$preview = createPreviewForPortTests($this->application, 114, [
'fqdn' => 'https://one-pr.example.com,https://two-pr.example.com',
'domain_port_overrides' => [
'https://one-pr.example.com' => 3000,
'https://two-pr.example.com' => 8080,
],
]);
$labels = collect(generateLabelsApplication($this->application->fresh(), $preview->fresh()));
$uuid = $this->application->uuid.'-pr-114';
expect($labels)
->toContain('traefik.http.routers.https-0-'.$uuid.'.rule=Host(`one-pr.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-0-'.$uuid.'.loadbalancer.server.port=3000')
->toContain('traefik.http.routers.https-1-'.$uuid.'.rule=Host(`two-pr.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-1-'.$uuid.'.loadbalancer.server.port=8080')
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}')
->toContain('caddy_1.handle_path.1_reverse_proxy={{upstreams 8080}}')
->not->toContain('Host(`one-pr.example.com:3000`)')
->not->toContain('Host(`two-pr.example.com:8080`)');
});
it('uses the first ports_exposes value for a portless preview domain without an override', function () {
$preview = createPreviewForPortTests($this->application, 115, [
'fqdn' => 'https://plain-pr.example.com',
'domain_port_overrides' => null,
]);
$labels = collect(generateLabelsApplication($this->application->fresh(), $preview->fresh()));
expect($labels)
->toContain('traefik.http.services.https-0-'.$this->application->uuid.'-pr-115.loadbalancer.server.port=3000')
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}');
});
it('keeps routing a legacy port-bearing preview FQDN without an override map', function () {
$preview = createPreviewForPortTests($this->application, 116, [
'fqdn' => 'https://legacy-pr.example.com',
]);
DB::table('application_previews')->where('id', $preview->id)->update([
'fqdn' => 'https://legacy-pr.example.com:9090',
'domain_port_overrides' => null,
]);
$labels = collect(generateLabelsApplication($this->application->fresh(), $preview->fresh()));
expect($labels)
->toContain('traefik.http.routers.https-0-'.$this->application->uuid.'-pr-116.rule=Host(`legacy-pr.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-0-'.$this->application->uuid.'-pr-116.loadbalancer.server.port=9090')
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 9090}}');
});
it('passes preview domain port overrides into compose pull-request labels', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'compose_parsing_version' => '3',
'docker_compose_raw' => <<<'YAML'
services:
frontend:
image: myapp/frontend:latest
YAML,
'fqdn' => null,
'docker_compose_domains' => json_encode([
'frontend' => ['domain' => 'https://frontend.example.com'],
]),
'domain_port_overrides' => [
'https://frontend.example.com' => 80,
],
]);
$preview = createPreviewForPortTests($this->application, 117, [
'docker_compose_domains' => json_encode([
'frontend' => ['domain' => 'https://117.frontend.example.com'],
]),
'fqdn' => 'https://117.frontend.example.com',
'domain_port_overrides' => [
'https://117.frontend.example.com' => 8080,
],
]);
$parsedCompose = applicationParser($this->application->fresh(), 117, $preview->id);
$labels = collect(data_get($parsedCompose, 'services.frontend-pr-117.labels'));
expect($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=8080')))
->toBeTrue()
->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams 8080}}')))
->toBeTrue()
->and($labels->contains(fn (string $label): bool => str_contains($label, 'Host(`117.frontend.example.com`)')))
->toBeTrue()
->and($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=80')))
->toBeFalse();
});
it('uses ports_exposes as the compose preview fallback when a domain has no override', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'compose_parsing_version' => '3',
'ports_exposes' => '4000,5000',
'docker_compose_raw' => <<<'YAML'
services:
frontend:
image: myapp/frontend:latest
YAML,
'fqdn' => null,
'domain_port_overrides' => null,
'docker_compose_domains' => json_encode([
'frontend' => ['domain' => 'https://frontend.example.com'],
]),
]);
$preview = createPreviewForPortTests($this->application, 118, [
'docker_compose_domains' => json_encode([
'frontend' => ['domain' => 'https://118.frontend.example.com'],
]),
'fqdn' => 'https://118.frontend.example.com',
'domain_port_overrides' => null,
]);
$parsedCompose = applicationParser($this->application->fresh(), 118, $preview->id);
$labels = collect(data_get($parsedCompose, 'services.frontend-pr-118.labels'));
expect($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=4000')))
->toBeTrue()
->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams 4000}}')))
->toBeTrue();
});
it('shows a port warning when a preview domain uses a port that is not exposed or used by the application', function () {
$preview = createPreviewForPortTests($this->application, 119);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->set('newDomainParts.host', 'preview.example.com')
->set('newDomainParts.port', '9090')
->call('addDomain')
->assertSet('showPortWarningModal', true)
->assertSet('unrecognizedPort', 9090)
->assertSee('Use a different port?')
->assertSee('9090');
expect($preview->fresh()->fqdn)->toBeNull();
});
it('does not warn when a preview domain port is listed in ports exposes', function () {
$preview = createPreviewForPortTests($this->application, 120);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->set('newDomainParts.host', 'preview.example.com')
->set('newDomainParts.port', '8080')
->call('addDomain')
->assertSet('showPortWarningModal', false)
->assertDispatched('success');
});
it('does not warn when a preview domain port is already used by an application domain', function () {
$this->application->update([
'ports_exposes' => '3000',
'fqdn' => 'https://app.example.com:9090',
]);
$preview = createPreviewForPortTests($this->application, 121);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->set('newDomainParts.host', 'preview.example.com')
->set('newDomainParts.port', '9090')
->call('addDomain')
->assertSet('showPortWarningModal', false)
->assertDispatched('success');
});
it('saves an unrecognized preview domain port after confirming the warning', function () {
$preview = createPreviewForPortTests($this->application, 122);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->set('newDomainParts.host', 'preview.example.com')
->set('newDomainParts.port', '9090')
->call('addDomain')
->assertSet('showPortWarningModal', true)
->call('confirmUseUnknownPort')
->assertSet('showPortWarningModal', false)
->assertDispatched('success');
expect($preview->fresh()->fqdn)->toBe('https://preview.example.com')
->and($preview->fresh()->domain_port_overrides)
->toBe(['https://preview.example.com' => 9090]);
});
it('cancels an unrecognized preview domain port without saving', function () {
$preview = createPreviewForPortTests($this->application, 123);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->set('newDomainParts.host', 'preview.example.com')
->set('newDomainParts.port', '9090')
->call('addDomain')
->assertSet('showPortWarningModal', true)
->call('cancelUseUnknownPort')
->assertSet('showPortWarningModal', false);
expect($preview->fresh()->fqdn)->toBeNull();
});
it('warns when editing a preview domain to an unrecognized port', function () {
$preview = createPreviewForPortTests($this->application, 124, [
'fqdn' => 'https://preview.example.com:3000',
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->call('startEdit', 0)
->set('editingDomainParts.port', '5555')
->call('updateDomain')
->assertSet('showPortWarningModal', true)
->assertSet('unrecognizedPort', 5555);
expect($preview->fresh()->domain_port_overrides)
->toBe(['https://preview.example.com' => 3000]);
});
it('does not warn when re-saving a preview domain with the same custom port', function () {
$preview = createPreviewForPortTests($this->application, 125, [
'fqdn' => 'https://preview.example.com:9090',
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->call('startEdit', 0)
->set('editingDomainParts.port', '9090')
->call('updateDomain')
->assertSet('showPortWarningModal', false)
->assertDispatched('success');
});
@@ -2,7 +2,9 @@
use App\Livewire\Project\Service\Domains;
use App\Livewire\Project\Service\EditDomain;
use App\Livewire\Project\Service\Index;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
@@ -10,14 +12,21 @@ use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
InstanceSettings::forceCreate(['id' => 0]);
// Create user and team
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
// Create server
$this->server = Server::factory()->create([
@@ -25,9 +34,7 @@ beforeEach(function () {
]);
// Create standalone docker destination
$this->destination = StandaloneDocker::factory()->create([
'server_id' => $this->server->id,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
// Create project and environment
$this->project = Project::factory()->create([
@@ -48,8 +55,10 @@ beforeEach(function () {
]);
// Create service application
$this->serviceApplication = ServiceApplication::factory()->create([
$this->serviceApplication = ServiceApplication::create([
'service_id' => $this->service->id,
'name' => 'web',
'image' => 'nginx:alpine',
'fqdn' => 'http://example.com:8000',
]);
@@ -68,6 +77,42 @@ beforeEach(function () {
}
});
it('loads a persisted port override in the service application editor', function () {
$this->serviceApplication->update([
'fqdn' => 'https://web.example.com',
'domain_port_overrides' => [
'https://web.example.com' => 8080,
],
]);
Livewire::test(Index::class, [
'serviceApplication' => $this->serviceApplication->fresh(),
'parameters' => [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'service_uuid' => $this->service->uuid,
'stack_service_uuid' => $this->serviceApplication->uuid,
],
'query' => [],
])
->assertSet('fqdn', 'https://web.example.com:8080')
->assertOk();
});
it('initializes route state when mounting a service application directly', function () {
Livewire::test(Index::class, [
'serviceApplication' => $this->serviceApplication,
])
->assertSet('parameters', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'service_uuid' => $this->service->uuid,
'stack_service_uuid' => $this->serviceApplication->uuid,
])
->assertSet('query', [])
->assertOk();
});
it('loads the EditDomain component with required port', function () {
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
->assertSet('requiredPort', 8000)
@@ -75,6 +120,28 @@ it('loads the EditDomain component with required port', function () {
->assertOk();
});
it('loads a persisted port override and moves it when the hostname changes', function () {
$this->serviceApplication->update([
'fqdn' => 'https://old.example.com',
'domain_port_overrides' => [
'https://old.example.com' => 8080,
],
]);
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
->assertSet('fqdn', 'https://old.example.com:8080')
->set('fqdn', 'https://new.example.com:8080')
->call('submit')
->assertSet('showPortWarningModal', false)
->assertSet('fqdn', 'https://new.example.com:8080');
expect($this->serviceApplication->fresh())
->fqdn->toBe('https://new.example.com')
->domain_port_overrides->toBe([
'https://new.example.com' => 8080,
]);
});
it('marks noindex changes as pending configuration', function () {
$this->service->isConfigurationChanged(save: true);
@@ -107,7 +174,7 @@ it('allows port removal when user confirms', function () {
});
it('cancels port removal when user cancels', function () {
$originalFqdn = $this->serviceApplication->fqdn;
$originalFqdn = $this->serviceApplication->url;
Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id])
->set('fqdn', 'http://example.com') // Remove port
@@ -126,7 +193,10 @@ it('allows saving when port is changed to different port', function () {
// Verify the FQDN was updated
$this->serviceApplication->refresh();
expect($this->serviceApplication->fqdn)->toBe('http://example.com:3000');
expect($this->serviceApplication->fqdn)->toBe('http://example.com')
->and($this->serviceApplication->domain_port_overrides)->toBe([
'http://example.com' => 3000,
]);
});
it('allows saving when all domains have ports (multiple domains)', function () {
@@ -153,8 +223,10 @@ it('does not show warning for services without required port', function () {
'environment_id' => $this->environment->id,
]);
$appWithoutPort = ServiceApplication::factory()->create([
$appWithoutPort = ServiceApplication::create([
'service_id' => $serviceWithoutPort->id,
'name' => 'web',
'image' => 'nginx:alpine',
'fqdn' => 'http://example.com',
]);
@@ -20,6 +20,7 @@ uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
config()->set('app.maintenance.store', 'array');
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
@@ -173,6 +174,23 @@ describe('GET /api/v1/services/{uuid}/applications/{app_uuid}', function () {
$response->assertStatus(200);
$response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid, 'name' => 'web']);
});
test('returns an editable url with persisted port overrides', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$ctx->serviceApplication->update([
'fqdn' => 'https://web.example.com',
'domain_port_overrides' => [
'https://web.example.com' => 8080,
],
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}")
->assertSuccessful()
->assertJsonPath('url', 'https://web.example.com:8080')
->assertJsonMissingPath('domain_port_overrides');
});
});
describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () {
@@ -199,6 +217,34 @@ describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () {
expect($ctx->serviceApplication->human_name)->toBe('Web UI');
});
test('round trips and moves a port override when renaming a domain', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$ctx->serviceApplication->update([
'fqdn' => 'https://old.example.com',
'domain_port_overrides' => [
'https://old.example.com' => 8080,
],
]);
$url = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}")
->json('url');
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
'url' => str_replace('old.example.com', 'new.example.com', $url),
])->assertSuccessful()
->assertJsonPath('url', 'https://new.example.com:8080');
expect($ctx->serviceApplication->fresh())
->fqdn->toBe('https://new.example.com')
->domain_port_overrides->toBe([
'https://new.example.com' => 8080,
]);
});
test('updates the HTTP to HTTPS redirect setting', function () {
config(['app.maintenance.driver' => 'file']);
$ctx = createServiceWithApplicationForApiTest($this);
@@ -225,6 +271,19 @@ describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () {
$response->assertStatus(422);
});
test('returns 422 for a url port outside the valid TCP range', function (string $url) {
$ctx = createServiceWithApplicationForApiTest($this);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
'url' => $url,
])->assertUnprocessable();
})->with([
'zero' => 'https://example.com:0',
'above maximum' => 'https://example.com:65536',
]);
test('returns 422 when enabling log drain but server has no log drain', function () {
$ctx = createServiceWithApplicationForApiTest($this);
@@ -63,7 +63,8 @@ YAML;
$serviceApp->refresh();
expect($serviceApp->fqdn)->toBe('http://git.example.com:80')
expect($serviceApp->fqdn)->toBe('http://git.example.com')
->and($serviceApp->domain_port_overrides['http://git.example.com'] ?? null)->toBe(80)
->and($serviceApp->fqdn)->not->toContain('//:80')
->and(fn () => Url::fromString($serviceApp->fqdn))->not->toThrow(Throwable::class);
+226 -3
View File
@@ -92,6 +92,40 @@ beforeEach(function () {
]);
});
it('marks service application port-only changes as pending configuration', function () {
$this->webApp->update([
'fqdn' => 'http://example.com',
'domain_port_overrides' => ['http://example.com' => 8000],
]);
$this->service->isConfigurationChanged(save: true);
$this->webApp->update([
'domain_port_overrides' => ['http://example.com' => 3000],
]);
expect($this->service->refresh()->isConfigurationChanged())->toBeTrue();
});
it('does not mark reordered service application port overrides as changed', function () {
$this->webApp->update([
'fqdn' => 'http://one.example.com,http://two.example.com',
'domain_port_overrides' => [
'http://one.example.com' => 8000,
'http://two.example.com' => 3000,
],
]);
$this->service->isConfigurationChanged(save: true);
$this->webApp->update([
'domain_port_overrides' => [
'http://two.example.com' => 3000,
'http://one.example.com' => 8000,
],
]);
expect($this->service->refresh()->isConfigurationChanged())->toBeFalse();
});
it('groups configured domains and shows redirect settings in the table', function () {
$this->apiApp->update([
'fqdn' => 'https://api.example.com,https://admin.example.com',
@@ -504,6 +538,133 @@ it('does not restore stale dns status when a removed service domain is re-added'
->and($this->apiApp->domain_dns_statuses['https://api.example.com']['message'] ?? null)->not->toBe('Stale DNS result.');
});
it('shows the port warning modal when adding a domain with a non-default port', function () {
$this->service->update([
'docker_compose_raw' => <<<'YAML'
services:
web:
image: nginx:alpine
environment:
- SERVICE_FQDN_WEB_8000
api:
image: node:alpine
YAML,
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set('newServiceApplicationId', $this->webApp->id)
->set('newDomainParts.host', 'web.example.com')
->set('newDomainParts.port', '3000')
->set('newDomainPartsChanged', true)
->call('addDomain')
->assertSet('showPortWarningModal', true)
->assertSet('requiredPort', 8000)
->assertSee('Use a different port?');
expect($this->webApp->fresh()->fqdn)->toBeNull();
});
it('saves a non-default domain port after confirming the warning', function () {
$this->service->update([
'docker_compose_raw' => <<<'YAML'
services:
web:
image: nginx:alpine
environment:
- SERVICE_FQDN_WEB_8000
api:
image: node:alpine
YAML,
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set('newServiceApplicationId', $this->webApp->id)
->set('newDomainParts.host', 'web.example.com')
->set('newDomainParts.port', '3000')
->set('newDomainPartsChanged', true)
->call('addDomain')
->assertSet('showPortWarningModal', true)
->call('confirmRemovePort')
->assertSet('showPortWarningModal', false)
->assertDispatched('success');
$this->webApp->refresh();
expect($this->webApp->fqdn)->toContain('https://web.example.com')
->and($this->webApp->domain_port_overrides['https://web.example.com'] ?? null)->toBe(3000);
});
it('clears a service domain port override when saving without a port', function () {
$this->service->update([
'docker_compose_raw' => <<<'YAML'
services:
api:
image: node:alpine
environment:
- SERVICE_FQDN_API_3000
web:
image: nginx:alpine
YAML,
]);
$this->apiApp->update([
'fqdn' => 'https://api.example.com',
'domain_port_overrides' => [
'https://api.example.com' => 8080,
],
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '8080')
->set('editingDomainParts.port', '')
->call('updateDomain')
->assertHasNoErrors()
->assertSee('Internal port 3000')
->assertDontSee('Internal port 8080');
expect($this->apiApp->fresh()->domain_port_overrides ?? [])
->not->toHaveKey('https://api.example.com');
});
it('reopens service domain edit with the saved port override', function () {
$this->apiApp->update([
'fqdn' => 'https://api.example.com',
'domain_port_overrides' => [
'https://api.example.com' => 8080,
],
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSet('domainRows.0.url', 'https://api.example.com')
->assertSet('domainRows.0.internal_port', 8080)
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '8080')
->assertSet('editingDomainParts.host', 'api.example.com');
});
it('does not show the port warning modal when the domain uses the required port', function () {
$this->service->update([
'docker_compose_raw' => <<<'YAML'
services:
web:
image: nginx:alpine
environment:
- SERVICE_FQDN_WEB_8000
api:
image: node:alpine
YAML,
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set('newServiceApplicationId', $this->webApp->id)
->set('newDomainParts.host', 'web.example.com')
->set('newDomainParts.port', '8000')
->set('newDomainPartsChanged', true)
->call('addDomain')
->assertSet('showPortWarningModal', false)
->assertDispatched('success');
});
it('saves after confirming both a domain conflict and a missing required port', function () {
$this->service->update([
'docker_compose_raw' => <<<'YAML'
@@ -526,9 +687,9 @@ YAML,
->assertSet('showDomainConflictModal', false)
->assertSet('showPortWarningModal', true)
->assertSet('forceSaveDomains', true)
->assertSee('Remove required port?')
->assertSee('Keep port')
->assertSee('Remove port anyway');
->assertSee('Use a different port?')
->assertSee('Keep required port')
->assertSee('Use this port anyway');
$component
->call('confirmRemovePort')
@@ -690,3 +851,65 @@ it('updates search engine indexing from the service domains view', function () {
expect(file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php')))
->not->toContain('<select');
});
it('keeps noindex domains when normalizing a custom service domain port', function () {
$this->apiApp->update([
'fqdn' => 'https://api.example.com:8080',
'noindex_domains' => ['https://api.example.com:8080'],
]);
expect($this->apiApp->refresh())
->fqdn->toBe('https://api.example.com')
->noindex_domains->toBe(['https://api.example.com'])
->and($this->apiApp->domain_port_overrides)
->toBe(['https://api.example.com' => 8080]);
});
it('shows an inherited internal port badge from the coolify service env port', function () {
$this->service->update([
'docker_compose_raw' => "services:\n api:\n image: node:alpine\n environment:\n - SERVICE_FQDN_API_3000\n",
]);
$this->apiApp->update([
'fqdn' => 'https://api.example.com',
'domain_port_overrides' => null,
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSet('domainRows.0.internal_port', 3000)
->assertSet('domainRows.0.has_port_override', false)
->assertSee('Internal port 3000')
->assertSee('Inherited from the Coolify service port', false)
->assertDontSee('No internal port');
});
it('shows a custom internal port badge for a service domain override', function () {
$this->service->update([
'docker_compose_raw' => "services:\n api:\n image: node:alpine\n environment:\n - SERVICE_FQDN_API_3000\n",
]);
$this->apiApp->update([
'fqdn' => 'https://api.example.com',
'domain_port_overrides' => [
'https://api.example.com' => 8080,
],
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSet('domainRows.0.internal_port', 8080)
->assertSet('domainRows.0.has_port_override', true)
->assertSee('Internal port 8080')
->assertSee('Custom internal port for this domain', false)
->assertDontSee('Internal port 3000');
});
it('does not show an internal port badge when the service has no env port', function () {
$this->apiApp->update([
'fqdn' => 'https://api.example.com',
'domain_port_overrides' => null,
]);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSet('domainRows.0.internal_port', null)
->assertDontSee('No internal port')
->assertDontSee('Internal port ')
->assertDontSee('table-badge-danger', false);
});
@@ -0,0 +1,48 @@
<?php
/**
* One-click templates that expose SERVICE_URL / SERVICE_FQDN without a _PORT
* suffix (WordPress-style) must declare `# port:` so getRequiredPort() can
* fall back for the badge and proxy.
*/
it('requires a template port for HTTP compose services that omit SERVICE_*_PORT', function () {
$templates = get_service_templates();
$missing = [];
foreach (glob(base_path('templates/compose/*.{yaml,yml}'), GLOB_BRACE) as $file) {
$name = pathinfo($file, PATHINFO_FILENAME);
if (! $templates->has($name)) {
continue;
}
$text = file_get_contents($file);
$declaresHttpUrlWithoutPort = false;
foreach (preg_split("/\r\n|\n|\r/", $text) as $line) {
$line = trim($line);
if (! preg_match('/^- SERVICE_(?:URL|FQDN)_([A-Z0-9_]+)$/', $line, $match)
&& ! preg_match('/^SERVICE_(?:URL|FQDN)_([A-Z0-9_]+):/', $line, $match)) {
continue;
}
if (! preg_match('/_\d+$/', $match[1])) {
$declaresHttpUrlWithoutPort = true;
break;
}
}
if (! $declaresHttpUrlWithoutPort) {
continue;
}
$yamlPort = null;
if (preg_match('/^#\s*port:\s*(\d+)/m', $text, $portMatch)) {
$yamlPort = $portMatch[1];
}
$jsonPort = data_get($templates, "{$name}.port");
if (! $yamlPort || ! filled($jsonPort)) {
$missing[] = $name;
}
}
expect($missing)->toBeEmpty('HTTP templates without SERVICE_*_PORT need # port: and JSON port: '.implode(', ', $missing));
});
@@ -0,0 +1,27 @@
<?php
use App\Models\Service;
use App\Models\ServiceApplication;
it('resolves the wordpress template port from service_type even when the display name differs', function () {
expect(data_get(get_service_templates(), 'wordpress-without-database.port'))->toBe('80');
$service = new Service([
'name' => 'api-smoke-wp',
'service_type' => 'wordpress-without-database',
'docker_compose_raw' => <<<'YAML'
services:
wordpress:
image: wordpress:latest
environment:
- SERVICE_URL_WORDPRESS
YAML,
]);
expect($service->getRequiredPort())->toBe(80);
$app = new ServiceApplication(['name' => 'wordpress']);
$app->setRelation('service', $service);
expect($app->getRequiredPort())->toBe(80);
});
@@ -29,6 +29,14 @@ it('ensures label parsing converts array values to strings', function () {
->toContain('$removedLabel = (string) collect($removedLabel)->first();');
});
it('falls back to the template port for service application proxy labels', function () {
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
expect($sharedFile)->toContain(
'? ($savedService->getRequiredPort() ?? $predefinedPort)'
);
});
it('verifies label parsing array check occurs before preg_match', function () {
// Read the parseDockerComposeFile function from shared.php
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
+36
View File
@@ -0,0 +1,36 @@
<?php
use App\Support\DomainPortOverrides;
it('copies the source port override to an automatically paired domain', function (string $source, string $counterpart) {
$result = DomainPortOverrides::normalize(
"$source,$counterpart",
[$source => 8080],
);
expect($result['overrides'])->toBe([
$source => 8080,
$counterpart => 8080,
]);
})->with([
'www redirect' => ['https://example.com', 'https://www.example.com'],
'non-www redirect' => ['https://www.example.com', 'https://example.com'],
]);
it('keeps an explicit override on the paired domain', function (string $source, string $counterpart) {
$result = DomainPortOverrides::normalize(
"$source,$counterpart",
[
$source => 8080,
$counterpart => 9090,
],
);
expect($result['overrides'])->toBe([
$source => 8080,
$counterpart => 9090,
]);
})->with([
'www redirect' => ['https://example.com', 'https://www.example.com'],
'non-www redirect' => ['https://www.example.com', 'https://example.com'],
]);
+52
View File
@@ -232,3 +232,55 @@ describe('Caddy noindex header', function () {
)->all())->toBeEmpty();
});
});
test('fqdnLabelsForCaddy routes each portless domain to its override port', function () {
$labels = fqdnLabelsForCaddy(
network: 'testnetwork',
uuid: 'appuuid',
domains: collect(['https://one.example.com', 'https://two.example.com']),
onlyPort: 80,
is_force_https_enabled: true,
domainPortOverrides: [
'https://one.example.com' => 3000,
'https://two.example.com' => 8080,
],
)->values()->all();
expect($labels)
->toContain('caddy_0=https://one.example.com')
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}')
->toContain('caddy_1=https://two.example.com')
->toContain('caddy_1.handle_path.1_reverse_proxy={{upstreams 8080}}')
->not->toContain('caddy_0=https://one.example.com:3000')
->not->toContain('caddy_1=https://two.example.com:8080');
});
test('fqdnLabelsForCaddy uses onlyPort when a portless domain has no override', function () {
$labels = fqdnLabelsForCaddy(
network: 'testnetwork',
uuid: 'appuuid',
domains: collect(['https://plain.example.com']),
onlyPort: 4000,
is_force_https_enabled: true,
domainPortOverrides: [],
)->values()->all();
expect($labels)
->toContain('caddy_0=https://plain.example.com')
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 4000}}');
});
test('fqdnLabelsForCaddy keeps routing a legacy port-bearing FQDN without an override map', function () {
$labels = fqdnLabelsForCaddy(
network: 'testnetwork',
uuid: 'appuuid',
domains: collect(['https://legacy.example.com:9090']),
onlyPort: 80,
is_force_https_enabled: true,
domainPortOverrides: [],
)->values()->all();
expect($labels)
->toContain('caddy_0=https://legacy.example.com')
->toContain('caddy_0.handle_path.0_reverse_proxy={{upstreams 9090}}');
});
+41
View File
@@ -198,6 +198,47 @@ YAML;
expect($result)->toBe(3000);
});
it('falls back to the one-click template port when SERVICE_URL has no port suffix', function () {
$yaml = <<<'YAML'
services:
wordpress:
environment:
- SERVICE_URL_WORDPRESS
- WORDPRESS_DB_HOST=mysql
YAML;
$service = Mockery::mock(Service::class)->makePartial();
$service->docker_compose_raw = $yaml;
$service->shouldReceive('getRequiredPort')->andReturn(80);
$app = Mockery::mock(ServiceApplication::class)->makePartial();
$app->name = 'wordpress';
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
$app->service = $service;
expect($app->getRequiredPort())->toBe(80);
});
it('does not apply the template port to a container without SERVICE_URL or SERVICE_FQDN', function () {
$yaml = <<<'YAML'
services:
mysql:
environment:
- MYSQL_DATABASE=wordpress
YAML;
$service = Mockery::mock(Service::class)->makePartial();
$service->docker_compose_raw = $yaml;
$service->shouldReceive('getRequiredPort')->andReturn(80);
$app = Mockery::mock(ServiceApplication::class)->makePartial();
$app->name = 'mysql';
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
$app->service = $service;
expect($app->getRequiredPort())->toBeNull();
});
it('returns null for map-style environment without port', function () {
$yaml = <<<'YAML'
services:
@@ -107,3 +107,49 @@ test('application labels keep redirect capture groups single escaped before comp
expect($labels)
->toContain('traefik.http.middlewares.0-application-uuid-to-www.redirectregex.replacement=${1}://www.${2}');
});
test('fqdnLabelsForTraefik routes each portless domain to its override port', function () {
$labels = fqdnLabelsForTraefik(
uuid: 'appuuid',
domains: collect(['https://one.example.com', 'https://two.example.com']),
onlyPort: 80,
domainPortOverrides: [
'https://one.example.com' => 3000,
'https://two.example.com' => 8080,
],
);
expect($labels)
->toContain('traefik.http.routers.https-0-appuuid.rule=Host(`one.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-0-appuuid.loadbalancer.server.port=3000')
->toContain('traefik.http.routers.https-1-appuuid.rule=Host(`two.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-1-appuuid.loadbalancer.server.port=8080')
->not->toContain('Host(`one.example.com:3000`)')
->not->toContain('Host(`two.example.com:8080`)');
});
test('fqdnLabelsForTraefik uses onlyPort when a portless domain has no override', function () {
$labels = fqdnLabelsForTraefik(
uuid: 'appuuid',
domains: collect(['https://plain.example.com']),
onlyPort: 4000,
domainPortOverrides: [],
);
expect($labels)
->toContain('traefik.http.routers.https-0-appuuid.rule=Host(`plain.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-0-appuuid.loadbalancer.server.port=4000');
});
test('fqdnLabelsForTraefik keeps routing a legacy port-bearing FQDN without an override map', function () {
$labels = fqdnLabelsForTraefik(
uuid: 'appuuid',
domains: collect(['https://legacy.example.com:9090']),
onlyPort: 80,
domainPortOverrides: [],
);
expect($labels)
->toContain('traefik.http.routers.https-0-appuuid.rule=Host(`legacy.example.com`) && PathPrefix(`/`)')
->toContain('traefik.http.services.https-0-appuuid.loadbalancer.server.port=9090');
});
+14
View File
@@ -224,3 +224,17 @@ it('rejects single-label application hostnames but allows IP addresses', functio
->and(ValidationPatterns::validateApplicationDomains('https://localhost'))->not->toBeEmpty()
->and(ValidationPatterns::validateApplicationDomains('http://192.0.2.10:8000'))->toBeEmpty();
});
it('rejects application domain ports outside the valid TCP range', function (string $domain) {
expect(ValidationPatterns::validateApplicationDomains($domain))->not->toBeEmpty();
})->with([
'zero' => 'https://example.com:0',
'above maximum' => 'https://example.com:65536',
]);
it('accepts application domain ports at the TCP range boundaries', function (string $domain) {
expect(ValidationPatterns::validateApplicationDomains($domain))->toBeEmpty();
})->with([
'minimum' => 'https://example.com:1',
'maximum' => 'https://example.com:65535',
]);