withoutVite(); 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, ]); 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' => 'Domains App', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'fqdn' => null, 'redirect' => 'both', 'build_pack' => 'nixpacks', ]); $this->application->settings()->update([ 'is_container_label_readonly_enabled' => true, ]); }); it('uses safe domain validation rules on the domains form', function () { $component = new Domains; $method = new ReflectionMethod($component, 'rules'); $rules = $method->invoke($component); $validator = validator([ 'newDomain' => 'http://$(whoami).example.com', ], [ 'newDomain' => $rules['newDomain'], ]); expect($validator->fails())->toBeTrue() ->and($validator->errors()->has('newDomain'))->toBeTrue(); }); it('lists existing domains as individual rows', function () { $this->application->update([ 'fqdn' => 'https://example.com,https://www.example.com,https://another.example.com,https://www.another.example.com', ]); $html = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSuccessful() ->assertSet('domainRows.0.url', 'https://example.com') ->assertSet('domainRows.1.url', 'https://www.example.com') ->assertSee('https://example.com') ->assertSee('https://www.example.com') ->assertSee('https://example.com/favicon.ico', false) ->assertSee('class="relative size-4 shrink-0"', false) ->assertSee('domain-favicon-fallback', false) ->assertSee('class="invisible absolute inset-0 size-4 rounded-sm"', false) ->assertSee('$el.previousElementSibling.classList.add(\'hidden\')', false) ->assertSee('x-on:error="$el.remove()"', false) ->assertSee('class="min-w-0 flex-1 text-[13px]', false) ->html(); expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(2); }); it('shows one redirect direction control in each compose service header', function () { $this->application->update([ 'build_pack' => 'dockercompose', 'docker_compose_raw' => "services:\n api:\n image: nginx:alpine\n", 'docker_compose_domains' => json_encode([ 'api' => [ 'domain' => 'https://api.example.com,https://www.api.example.com', 'redirect' => 'www', ], ]), ]); $html = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSuccessful() ->assertSee('api') ->html(); expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1) ->and(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0) ->and(substr_count($html, 'domain-direction-service-api'))->toBeGreaterThan(0); }); it('shows dns entries control next to Add', function () { Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSuccessful() ->assertSee('DNS entries') ->assertSee('Manual records'); }); it('shows dns entries when domains are managed through labels', function () { $this->application->settings->update([ 'is_container_label_readonly_enabled' => false, ]); Livewire::test(Domains::class, ['application' => $this->application->fresh(['settings'])]) ->assertSuccessful() ->assertSee('DNS entries') ->assertSee('Manual records'); }); it('lists dns entries for domains that still need dns and omits working configured hosts', function () { $this->application->update([ 'fqdn' => 'https://app.example.com,https://www.example.com,https://api.example.com', 'domain_dns_statuses' => [ 'https://app.example.com' => [ 'status' => 'ok', 'message' => 'OK', 'expected_ip' => '203.0.113.10', 'checked_at' => now()->toIso8601String(), ], 'https://api.example.com' => [ 'status' => 'failed', 'message' => 'Mismatch', 'expected_ip' => '203.0.113.10', 'checked_at' => now()->toIso8601String(), ], ], ]); $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call('openDnsRecordsModal') ->assertSet('showDnsRecordsModal', true) ->assertSee('Recheck'); $names = collect($component->instance()->dnsRecordHints())->pluck('name')->all(); expect($names) ->toContain('api.example.com') ->not->toContain('app.example.com'); }); it('shows cloudflare domain connect only on cloud with a key', function () { config([ 'constants.coolify.self_hosted' => false, 'services.domain_connect.private_key' => null, ]); $key = openssl_pkey_new([ 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, ]); openssl_pkey_export($key, $privateKey); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertDontSee('Cloudflare'); config(['services.domain_connect.private_key' => $privateKey]); $this->application->update(['fqdn' => 'https://app.example.com']); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSee('Cloudflare') ->call('openCloudflareAutoconfigureModal') ->assertSet('showCloudflareAutoconfigureModal', true) ->assertSee('app.example.com') ->assertDontSee('Domain or hostname') ->call('applyCloudflareAutoconfigure') ->assertHasNoErrors() ->assertSet('showCloudflareAutoconfigureModal', false); }); it('adds a domain to the application', function () { Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSee('+ Add') ->set('newDomain', 'https://app.example.com') ->call('addDomain') ->assertHasNoErrors() ->assertSet('addDomainDnsFailed', false) ->assertDispatched('success') ->assertDispatched('close-modal'); $this->application->refresh(); expect(explode(',', (string) $this->application->fqdn)) ->toBe(['https://app.example.com', 'https://www.app.example.com']); }); it('composes the complete port on the server without duplicating an existing www domain', function () { $this->application->update(['fqdn' => 'https://www.example.com:3000']); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('newDomainParts.host', 'example.com') ->set('newDomainParts.port', '3000') ->call('addDomain') ->assertHasNoErrors() ->assertDispatched('success'); expect(explode(',', (string) $this->application->fresh()->fqdn))->toBe([ 'https://www.example.com:3000', 'https://example.com:3000', ]); }); it('adds multiple domains without replacing existing ones', function () { $this->application->update([ 'fqdn' => 'https://app.example.com', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('newDomain', 'https://api.example.com') ->call('addDomain') ->assertHasNoErrors(); $this->application->refresh(); expect(explode(',', (string) $this->application->fqdn)) ->toContain('https://app.example.com') ->toContain('https://api.example.com'); }); it('blocks adding a domain with bad dns until the user continues', function () { $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = true; $settings->save(); $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid') ->call('addDomain') ->assertSet('addDomainDnsFailed', true) ->assertSee('DNS is not pointing to the right IP') ->assertSee('Are you sure you want to add it anyway'); $this->application->refresh(); expect($this->application->fqdn)->toBeNull(); $component->call('confirmAddDomainDespiteDns') ->assertSet('addDomainDnsFailed', false) ->assertDispatched('success') ->assertDispatched('close-modal'); $this->application->refresh(); expect(explode(',', (string) $this->application->fqdn))->toBe([ 'https://this-domain-should-not-resolve-for-coolify-tests.invalid', 'https://www.this-domain-should-not-resolve-for-coolify-tests.invalid', ]); }); it('resets the dns gate when the domain input changes', function () { $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = true; $settings->save(); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid') ->call('addDomain') ->assertSet('addDomainDnsFailed', true) ->set('newDomain', 'https://another.example.com') ->assertSet('addDomainDnsFailed', false) ->assertSet('forceSaveDns', false); }); it('updates a domain in place via modal', function () { $this->application->update([ 'fqdn' => 'https://old.example.com,https://keep.example.com', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call('startEdit', 0) ->assertSet('showEditDomainModal', true) ->assertSet('editingDomain', 'https://old.example.com') ->assertSee('Direction') ->assertSee('Search engine indexing') ->set('editingDomain', 'https://new.example.com') ->call('updateDomain') ->assertHasNoErrors() ->assertSet('showEditDomainModal', false) ->assertDispatched('edit-domain-saved') ->assertDispatched('success'); $this->application->refresh(); expect($this->application->fqdn)->toBe('https://new.example.com,https://keep.example.com'); }); it('blocks editing a domain with bad dns until the user continues', function () { $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = true; $settings->save(); $this->application->update([ 'fqdn' => 'https://old.example.com', ]); $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call('startEdit', 0) ->set('editingDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid') ->call('updateDomain') ->assertSet('editDomainDnsFailed', true) ->assertSet('showEditDomainModal', true) ->assertSee('DNS is not pointing to the right IP') ->assertSee('Are you sure you want to save it anyway'); $this->application->refresh(); expect($this->application->fqdn)->toBe('https://old.example.com'); $component->call('confirmUpdateDomainDespiteDns') ->assertSet('editDomainDnsFailed', false) ->assertSet('showEditDomainModal', false) ->assertDispatched('success'); $this->application->refresh(); expect($this->application->fqdn)->toBe('https://this-domain-should-not-resolve-for-coolify-tests.invalid'); }); it('removes a domain', function () { $this->application->update([ 'fqdn' => 'https://app.example.com,https://www.example.com', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call('removeDomain', 0) ->assertDispatched('success'); $this->application->refresh(); expect($this->application->fqdn)->toBe('https://www.example.com'); }); it('does not revalidate dns on remaining domains when removing one', function () { $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = true; $settings->save(); $this->application->update([ 'fqdn' => 'https://keep-this-should-not-resolve-for-coolify-tests.invalid,https://remove-this-should-not-resolve-for-coolify-tests.invalid', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call('removeDomain', 1) ->assertDispatched('success') ->assertNotDispatched('error'); $this->application->refresh(); expect($this->application->fqdn)->toBe('https://keep-this-should-not-resolve-for-coolify-tests.invalid'); }); it('sets redirect direction when www domain exists', function () { $this->application->update([ 'fqdn' => 'https://example.com,https://www.example.com', 'redirect' => 'both', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('redirect', 'www') ->call('setRedirect') ->assertDispatched('success'); $this->application->refresh(); expect($this->application->redirect)->toBe('www'); }); it('auto-adds missing www counterpart as a normal domain when setting www redirect', function () { $this->application->update([ 'fqdn' => 'https://example.com', 'redirect' => 'both', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('redirect', 'www') ->call('setRedirect') ->assertDispatched('success') ->assertSet('domainRows.0.is_suggested', false) ->assertSet('domainRows.1.is_suggested', false) ->assertSet('domainRows.0.url', 'https://example.com') ->assertSet('domainRows.1.url', 'https://www.example.com') ->assertSet('domainRows.1.checked_at', fn (?string $checkedAt): bool => filled($checkedAt)); $this->application->refresh(); expect($this->application->redirect)->toBe('www') ->and(explode(',', (string) $this->application->fqdn)) ->toContain('https://example.com') ->toContain('https://www.example.com'); }); it('does not re-add a removed www counterpart on page load when redirect is www', function () { $this->application->update([ 'fqdn' => 'https://asd.hu', 'redirect' => 'www', ]); // Mount alone must not re-add missing pairs (would undo deletes). Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSet('domainRows.0.url', 'https://asd.hu'); expect(explode(',', (string) $this->application->fresh()->fqdn)) ->toContain('https://asd.hu') ->not->toContain('https://www.asd.hu'); }); it('keeps a domain removed when its www counterpart remains and redirect is www', function () { $this->application->update([ 'fqdn' => 'https://asd.hu,https://www.asd.hu', 'redirect' => 'www', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call('removeDomain', 0) ->assertDispatched('success'); $this->application->refresh(); expect(explode(',', (string) $this->application->fqdn)) ->toContain('https://www.asd.hu') ->not->toContain('https://asd.hu'); }); it('auto-adds www pair when adding a domain while redirect is www', function () { $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = false; $settings->save(); $this->application->update([ 'fqdn' => null, 'redirect' => 'www', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('newDomain', 'https://app.example.com') ->call('addDomain') ->assertHasNoErrors() ->assertDispatched('success'); $this->application->refresh(); expect(explode(',', (string) $this->application->fqdn)) ->toContain('https://app.example.com') ->toContain('https://www.app.example.com'); }); it('auto-adds the suggested www pair when adding a domain with both directions', function () { $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = false; $settings->save(); $this->application->update([ 'fqdn' => null, 'redirect' => 'both', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('newDomain', 'https://app.example.com') ->call('addDomain') ->assertHasNoErrors() ->assertDispatched('success'); expect(explode(',', (string) $this->application->fresh()->fqdn)) ->toContain('https://app.example.com') ->toContain('https://www.app.example.com'); }); it('appends a generated domain without replacing existing domains', function () { $this->application->update([ 'fqdn' => 'https://existing.example.com,https://www.existing.example.com', 'redirect' => 'both', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call('generateDomain') ->assertDispatched('success') ->assertNotDispatched('error'); $domains = explode(',', (string) $this->application->fresh()->fqdn); expect($domains) ->toContain('https://existing.example.com') ->toContain('https://www.existing.example.com') ->toHaveCount(3); }); it('auto-adds missing non-www counterpart as a normal domain when setting non-www redirect', function () { $this->application->update([ 'fqdn' => 'https://www.example.com', 'redirect' => 'both', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('redirect', 'non-www') ->call('setRedirect') ->assertDispatched('success'); $this->application->refresh(); expect($this->application->redirect)->toBe('non-www') ->and(explode(',', (string) $this->application->fqdn)) ->toContain('https://www.example.com') ->toContain('https://example.com'); }); it('saves redirect after confirming a conflict for an auto-added www pair', function () { Application::factory()->create([ 'uuid' => (string) Str::uuid(), 'name' => 'WWW Taken App', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'fqdn' => 'https://www.example.com', 'build_pack' => 'nixpacks', ]); $this->application->update([ 'fqdn' => 'https://example.com', 'redirect' => 'both', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('redirect', 'www') ->call('setRedirect') ->assertSet('showDomainConflictModal', true) ->assertSet('pendingAction', 'redirect') ->call('confirmDomainUsage') ->assertSet('showDomainConflictModal', false) ->assertSet('pendingAction', null) ->assertDispatched('success'); $this->application->refresh(); expect($this->application->redirect)->toBe('www') ->and(explode(',', (string) $this->application->fqdn)) ->toContain('https://example.com') ->toContain('https://www.example.com'); }); it('marks dns status as skipped when dns validation is disabled', function () { $this->application->update([ 'fqdn' => 'https://app.example.com', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call('checkAllDns') ->assertSet('domainRows.0.dns_status', 'skipped'); $this->application->refresh(); $statuses = $this->application->domain_dns_statuses; expect($statuses)->toBeArray() ->and($statuses['https://app.example.com']['status'] ?? null)->toBe('skipped'); }); it('prevents members from running dns check actions', function (string $action, array $parameters) { $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); $this->actingAs($this->user->fresh()); $this->application->update([ 'fqdn' => 'https://app.example.com', 'domain_dns_statuses' => null, ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call($action, ...$parameters) ->assertForbidden(); expect($this->application->fresh()->domain_dns_statuses)->toBeNull(); })->with([ 'all domains' => ['checkAllDns', []], 'single domain' => ['checkDomainDns', [0]], ]); it('hides dns check buttons from members', function () { $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); $this->actingAs($this->user->fresh()); $this->application->update(['fqdn' => 'https://app.example.com']); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertDontSee('Recheck DNS') ->assertDontSee('Check DNS'); }); it('loads persisted dns status on page load', function () { $this->application->update([ 'fqdn' => 'https://app.example.com', 'domain_dns_statuses' => [ 'https://app.example.com' => [ 'status' => 'failed', 'message' => 'DNS does not point to 203.0.113.10.', 'expected_ip' => '203.0.113.10', 'checked_at' => now()->subHour()->toIso8601String(), ], ], ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSet('domainRows.0.dns_status', 'failed') ->assertSet('domainRows.0.dns_message', 'DNS does not point to 203.0.113.10.') ->assertSee('DNS mismatch') ->assertDontSee('DNS does not point to 203.0.113.10.') ->call('openDnsRecordsModal') ->assertSet('showDnsRecordsModal', true); }); it('shows dns mismatches before other domain entries', function () { $this->application->update([ 'fqdn' => 'https://healthy.example.com,https://broken.example.com', 'domain_dns_statuses' => [ 'https://healthy.example.com' => ['status' => 'ok', 'message' => 'OK'], 'https://broken.example.com' => ['status' => 'failed', 'message' => 'Mismatch'], ], ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSet('domainRows.0.url', 'https://broken.example.com') ->assertSet('domainRows.0.dns_status', 'failed') ->assertSet('domainRows.1.url', 'https://healthy.example.com'); }); it('hides dns message text when dns status is ok', function () { $this->application->update([ 'fqdn' => 'https://app.example.com', 'domain_dns_statuses' => [ 'https://app.example.com' => [ 'status' => 'ok', 'message' => 'DNS points to 203.0.113.10 (or Cloudflare).', 'expected_ip' => '203.0.113.10', 'checked_at' => now()->toIso8601String(), ], ], ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSet('domainRows.0.dns_status', 'ok') ->assertSee('DNS OK') ->assertDontSee('DNS points to 203.0.113.10') ->assertDontSee('Last checked'); }); it('persists dns status after checking a domain', function () { $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = true; $settings->save(); $domain = 'https://this-domain-should-not-resolve-for-coolify-tests.invalid'; $this->application->update([ 'fqdn' => $domain, ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call('checkDomainDns', 0) ->assertSet('domainRows.0.dns_status', 'failed'); $this->application->refresh(); $entry = $this->application->domain_dns_statuses[$domain] ?? null; expect($entry)->toBeArray() ->and($entry['status'] ?? null)->toBe('failed') ->and($entry['checked_at'] ?? null)->not->toBeNull(); }); it('resolves hostname server addresses to a real ip for dns messages', function () { $this->server->update(['ip' => 'localhost']); $this->application->update([ 'fqdn' => 'https://app.example.com', ]); // Enable DNS validation so checkAllDns produces ok/failed messages (not skipped). // Update the once()-cached model instance so subsequent instanceSettings() calls see it. $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = true; $settings->save(); $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); $resolvedIp = $component->get('serverIp'); expect($component->get('serverIpConfigured'))->toBe('localhost') ->and($resolvedIp)->not->toBe('localhost') ->and(filter_var($resolvedIp, FILTER_VALIDATE_IP))->not->toBeFalse() ->and($component->get('domainRows.0.expected_ip'))->toBe($resolvedIp); $component->call('checkAllDns'); $message = $component->get('domainRows.0.dns_message'); $recordType = dnsRecordTypeForIp($resolvedIp); // Failed checks show required DNS record guidance; ok checks mention the hostname label. if ($component->get('domainRows.0.dns_status') === 'failed') { expect($message)->toBe("{$recordType} record → {$resolvedIp}") ->and($message)->not->toContain('CNAME'); } else { expect($message)->toContain($resolvedIp) ->and($message)->toContain('localhost'); } }); it('uses short aaaa guidance when the server ip is ipv6', function () { $this->server->update(['ip' => '2001:db8::10']); $this->application->update([ 'fqdn' => 'https://this-domain-should-not-resolve-for-coolify-aaaa.invalid', ]); $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = true; $settings->save(); $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call('checkDomainDns', 0); expect($component->get('serverIp'))->toBe('2001:db8::10') ->and($component->get('domainRows.0.dns_message'))->toBe('Required DNS record type AAAA pointing to 2001:db8::10'); }); it('uses short a-record guidance for compose applications', function () { $this->application->update([ 'build_pack' => 'dockercompose', 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", 'docker_compose_domains' => json_encode([ 'web' => ['domain' => 'https://compose-dns-test.invalid'], ]), 'fqdn' => null, ]); $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = true; $settings->save(); $this->server->update(['ip' => '172.16.0.3']); $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call('checkDomainDns', 0); expect($component->get('domainRows.0.dns_message'))->toBe('Required DNS record type A pointing to 172.16.0.3') ->and($component->get('domainRows.0.dns_status'))->toBe('failed'); }); it('normalizes domains before saving', function () { Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('newDomain', 'HTTPS://App.Example.COM/Path') ->call('addDomain') ->assertHasNoErrors(); $this->application->refresh(); expect(explode(',', (string) $this->application->fqdn))->toBe([ ValidationPatterns::normalizeApplicationDomains('HTTPS://App.Example.COM/Path'), 'https://www.app.example.com/Path', ]); }); it('does not suggest a missing www counterpart', function () { $this->application->update([ 'fqdn' => 'https://example.com', 'redirect' => 'both', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->assertSet('domainRows.0.url', 'https://example.com') ->assertSet('domainRows.0.is_suggested', false) ->assertCount('domainRows', 1) ->assertDontSee('Not configured yet.') ->assertDontSee('https://www.example.com'); }); it('does not persist redirect until Set Direction saves', function () { $this->application->update([ 'fqdn' => 'https://example.com', 'redirect' => 'both', ]); $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('redirect', 'www'); expect($this->application->fresh()->redirect)->toBe('both'); $component ->call('setRedirect') ->assertDispatched('success'); $this->application->refresh(); expect($this->application->redirect)->toBe('www') ->and(explode(',', (string) $this->application->fqdn)) ->toContain('https://example.com') ->toContain('https://www.example.com'); }); it('does not persist compose service redirect until Set Direction is called', function () { $this->application->update([ 'build_pack' => 'dockercompose', 'fqdn' => null, 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", 'docker_compose_domains' => json_encode([ 'web' => ['domain' => 'https://web.example.com', 'redirect' => 'both'], ]), ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('isCompose', true) ->set('composeServices', ['web']) ->set('serviceRedirects.web', 'www'); $this->application->refresh(); $domains = json_decode($this->application->docker_compose_domains, true); expect(data_get($domains, 'web.redirect'))->toBe('both'); }); it('preserves per-service redirect when compose domains are updated through the api', function () { $this->application->update([ 'build_pack' => 'dockercompose', 'fqdn' => null, 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", 'docker_compose_domains' => json_encode([ 'web' => ['domain' => 'https://old.example.com', 'redirect' => 'both'], ]), ]); $plainTextToken = Str::random(40); $token = $this->user->tokens()->create([ 'name' => 'compose-domain-redirect-test', 'token' => hash('sha256', $plainTextToken), 'abilities' => ['*'], 'team_id' => $this->team->id, ]); auth()->logout(); $this->withToken($token->getKey().'|'.$plainTextToken) ->patchJson("/api/v1/applications/{$this->application->uuid}", [ 'docker_compose_domains' => [[ 'name' => 'web', 'domain' => 'https://new.example.com', 'redirect' => 'www', ]], ]) ->assertOk(); $domains = json_decode($this->application->fresh()->docker_compose_domains, true); expect($domains['web'])->toBe([ 'domain' => 'https://new.example.com', 'redirect' => 'www', ]); }); it('preserves omitted compose redirects while allowing explicit null', function () { $this->application->update([ 'build_pack' => 'dockercompose', 'fqdn' => null, 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", 'docker_compose_domains' => json_encode([ 'web' => ['domain' => 'https://old.example.com', 'redirect' => 'both'], ]), ]); $plainTextToken = Str::random(40); $token = $this->user->tokens()->create([ 'name' => 'legacy-compose-domain-test', 'token' => hash('sha256', $plainTextToken), 'abilities' => ['*'], 'team_id' => $this->team->id, ]); auth()->logout(); $this->withToken($token->getKey().'|'.$plainTextToken) ->patchJson("/api/v1/applications/{$this->application->uuid}", [ 'docker_compose_domains' => [[ 'name' => 'web', 'domain' => 'https://new.example.com', ]], ]) ->assertOk(); $domains = json_decode($this->application->fresh()->docker_compose_domains, true); expect($domains['web'])->toBe([ 'domain' => 'https://new.example.com', 'redirect' => 'both', ]); $this->withToken($token->getKey().'|'.$plainTextToken) ->patchJson("/api/v1/applications/{$this->application->uuid}", [ 'docker_compose_domains' => [[ 'name' => 'web', 'domain' => 'https://new.example.com', 'redirect' => null, ]], ]) ->assertOk(); $domains = json_decode($this->application->fresh()->docker_compose_domains, true); expect($domains['web'])->toBe([ 'domain' => 'https://new.example.com', ]); }); it('sets redirect for compose services whose names contain dots', function () { $this->application->update([ 'build_pack' => 'dockercompose', 'fqdn' => null, 'docker_compose_raw' => "services:\n api:\n image: node:alpine\n api.test:\n image: nginx:alpine\n", 'docker_compose_domains' => json_encode([ 'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'], 'api.test' => ['domain' => 'https://api-test.example.com', 'redirect' => 'both'], ]), ]); $wireKey = str_replace('.', '__dot__', 'api.test'); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('isCompose', true) ->set('composeServices', ['api', 'api.test']) ->set("serviceRedirects.{$wireKey}", 'non-www') ->call('setServiceRedirect', 'api.test') ->assertHasNoErrors() ->assertDispatched('success'); $this->application->refresh(); $domains = json_decode($this->application->docker_compose_domains, true); expect($domains['api.test']['redirect'] ?? null)->toBe('non-www') ->and($domains['api']['redirect'] ?? null)->toBe('both') // api remains a string-valued sibling, not nested by the dotted service binding ->and($domains['api']['domain'] ?? null)->toBe('https://api.example.com'); }); it('accepts service names wrapped in quotes from modal-confirmation', function () { $this->application->update([ 'build_pack' => 'dockercompose', 'fqdn' => null, 'docker_compose_raw' => "services:\n api:\n image: nginx:alpine\n", 'docker_compose_domains' => json_encode([ 'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'], ]), ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('isCompose', true) ->set('composeServices', ['api']) ->set('serviceRedirects.api', 'www') // modal-confirmation historically passed quoted string params + empty password ->call('setServiceRedirect', '"api"', '') ->assertHasNoErrors() ->assertDispatched('success'); $this->application->refresh(); $domains = json_decode($this->application->docker_compose_domains, true); expect($domains['api']['redirect'] ?? null)->toBe('www') ->and(explode(',', (string) ($domains['api']['domain'] ?? ''))) ->toContain('https://api.example.com') ->toContain('https://www.api.example.com'); }); it('auto-adds www pair for compose sslip domains when setting redirect', function () { $this->application->update([ 'build_pack' => 'dockercompose', 'fqdn' => null, 'docker_compose_raw' => "services:\n api:\n image: nginx:alpine\n", 'docker_compose_domains' => json_encode([ 'api' => [ 'domain' => 'http://api-docker-compose.127.0.0.1.sslip.io', 'redirect' => 'both', ], ]), ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('isCompose', true) ->set('composeServices', ['api']) ->set('serviceRedirects.api', 'www') ->call('setServiceRedirect', 'api') ->assertHasNoErrors() ->assertDispatched('success'); $this->application->refresh(); $domains = json_decode($this->application->docker_compose_domains, true); $apiDomains = explode(',', (string) ($domains['api']['domain'] ?? '')); expect($domains['api']['redirect'] ?? null)->toBe('www') ->and($apiDomains)->toContain('http://api-docker-compose.127.0.0.1.sslip.io') ->and($apiDomains)->toContain('http://www.api-docker-compose.127.0.0.1.sslip.io'); }); it('recovers when serviceRedirects.api is corrupted to a nested array by dotted service names', function () { $this->application->update([ 'build_pack' => 'dockercompose', 'fqdn' => null, 'docker_compose_raw' => "services:\n api:\n image: node:alpine\n api.test:\n image: nginx:alpine\n", 'docker_compose_domains' => json_encode([ 'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'], 'api.test' => ['domain' => 'https://api-test.example.com', 'redirect' => 'both'], ]), ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('isCompose', true) ->set('composeServices', ['api', 'api.test']) // Simulate broken nested state from wire:model="serviceRedirects.api.test" ->set('serviceRedirects', [ 'api' => ['test' => 'www'], 'api__dot__test' => 'both', ]) ->set('serviceRedirects.api', 'www') ->call('setServiceRedirect', 'api') ->assertHasNoErrors() ->assertDispatched('success'); $this->application->refresh(); $domains = json_decode($this->application->docker_compose_domains, true); expect($domains['api']['redirect'] ?? null)->toBe('www') ->and(explode(',', (string) ($domains['api']['domain'] ?? ''))) ->toContain('https://www.api.example.com'); }); it('saves after confirming a domain conflict on add', function () { Application::factory()->create([ 'uuid' => (string) Str::uuid(), 'name' => 'Conflicting App', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'fqdn' => 'https://shared.example.com', 'build_pack' => 'nixpacks', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('newDomain', 'https://shared.example.com') ->call('addDomain') ->assertSet('showDomainConflictModal', true) ->assertSet('pendingAction', 'add') ->assertSet('forceSaveDomains', false) ->call('confirmDomainUsage') ->assertSet('showDomainConflictModal', false) ->assertSet('forceSaveDomains', false) ->assertSet('pendingAction', null) ->assertDispatched('success'); expect($this->application->fresh()->fqdn)->toBe('https://shared.example.com'); }); it('saves after confirming a domain conflict on edit', function () { Application::factory()->create([ 'uuid' => (string) Str::uuid(), 'name' => 'Edit Conflict App', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'fqdn' => 'https://taken.example.com', 'build_pack' => 'nixpacks', ]); $this->application->update(['fqdn' => 'https://original.example.com']); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->call('startEdit', 0) ->set('editingDomain', 'https://taken.example.com') ->call('updateDomain') ->assertSet('showDomainConflictModal', true) ->assertSet('pendingAction', 'update') ->call('confirmDomainUsage') ->assertSet('showDomainConflictModal', false) ->assertSet('pendingAction', null) ->assertDispatched('success'); expect($this->application->fresh()->fqdn)->toBe('https://taken.example.com'); }); it('clears pending conflict action when the conflict modal is dismissed', function () { Application::factory()->create([ 'uuid' => (string) Str::uuid(), 'name' => 'Dismiss Conflict App', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'fqdn' => 'https://dismiss.example.com', 'build_pack' => 'nixpacks', ]); Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('newDomain', 'https://dismiss.example.com') ->call('addDomain') ->assertSet('showDomainConflictModal', true) ->assertSet('pendingAction', 'add') ->set('showDomainConflictModal', false) ->assertSet('pendingAction', null) ->assertSet('forceSaveDomains', false); expect($this->application->fresh()->fqdn)->toBeNull(); }); it('does not show a suggested row when both www variants are configured', function () { $this->application->update([ 'fqdn' => 'https://example.com,https://www.example.com', 'redirect' => 'both', ]); $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); expect(collect($component->get('domainRows'))->where('is_suggested', true))->toHaveCount(0); }); it('groups compose domains by service and hides empty services', function () { $this->application->update([ 'build_pack' => 'dockercompose', 'fqdn' => null, 'docker_compose_domains' => json_encode([ 'web' => ['domain' => 'https://web.example.com'], 'api' => ['domain' => null], ]), ]); $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); // parse() may not yield services in tests; force the compose service list used for grouping. $component->set('isCompose', true); $component->set('composeServices', ['web', 'api']); $component->instance()->domainRows = (function () use ($component) { $method = new ReflectionMethod($component->instance(), 'buildDomainRows'); return $method->invoke($component->instance()); })(); $component ->assertSee('web') ->assertSee('https://web.example.com') ->assertDontSee('No domains for this service'); }); it('uses the compact service domains layout for compose applications', function () { $view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php')); expect($view) ->toContain('application-compose-domain-group-{{ $redirectWireKey }}') ->toContain('class="application-settings-section-body mt-1 scroll-mt-28') ->toContain('bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]') ->toContain('class="data-table-header domains-table-grid-service"') ->toContain('Direction') ->toContain('Search engine indexing') ->not->toContain('Last checked') ->not->toContain('id="edit-domain-direction"') ->toContain('id="domain-direction-service-{{ $redirectWireKey }}"') ->toContain('onChange="updateServiceRedirect"') ->toContain("'showDirectionControl' => false") ->not->toContain('title="No domains for this service"'); }); it('keeps search engine indexing table headers on one line', function () { $applicationView = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php')); $serviceView = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php')); expect(substr_count($applicationView, 'Search engine indexing')) ->toBe(2) ->and($serviceView) ->toContain('Search engine indexing'); }); it('shows domain guidance in the application domains section', function () { $view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php')); expect($view) ->toContain('
')
->toContain('{{ $helperText }}');
});
it('does not render a last checked column in the domains table', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
expect($view)->not->toContain('Last checked')
->and($row)->not->toContain('$checkedAt');
});
it('uses compact labeled domain cards on mobile', function () {
$styles = file_get_contents(resource_path('css/app.css'));
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
expect($styles)
->toContain('@media (max-width: 768px)')
->toContain('.domains-mobile-label')
->toContain('.domains-table-grid .listbox-trigger')
->and($row)
->toContain('domains-mobile-label')
->toContain('Search engine indexing')
->toContain('Direction');
});
it('uses segmented fields when adding and editing application domains', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
$component = file_get_contents(resource_path('views/components/forms/domain-input.blade.php'));
expect($view)
->toContain('