mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-28 02:06:37 -04:00
feat(domains): support per-domain internal port overrides (#11594)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
@@ -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}}');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user