create(['metadata' => null]); $manualToken = IntegrationToken::factory()->create(['metadata' => ['automatic_dns' => false]]); expect($defaultToken->automaticDnsEnabled())->toBeTrue() ->and($manualToken->automaticDnsEnabled())->toBeFalse(); }); test('cloudflare zones are discovered and cached for a token', function () { $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret', 'capabilities' => ['dns']]); Http::fake(['https://api.cloudflare.com/client/v4/zones*' => Http::response([ 'success' => true, 'result' => [ ['id' => 'zone-1', 'name' => 'example.com', 'account' => ['id' => 'account-1', 'name' => 'Production']], ['id' => 'zone-2', 'name' => 'example.org', 'account' => ['id' => 'account-1', 'name' => 'Production']], ], 'result_info' => ['page' => 1, 'total_pages' => 1], ])]); app(CloudflareDnsProvider::class)->syncZones($token); expect($token->dnsZones()->orderBy('name')->pluck('name')->all())->toBe(['example.com', 'example.org']) ->and($token->fresh()->metadata['zones_synced_at'])->not->toBeNull(); }); test('all credentials for the most specific zone are returned without suffix false positives', function () { $team = Team::factory()->create(); $firstToken = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare']); $secondToken = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare']); DnsProviderZone::factory()->for($firstToken)->create(['name' => 'example.com']); DnsProviderZone::factory()->for($secondToken)->create(['name' => 'example.com']); DnsProviderZone::factory()->for($firstToken)->create(['name' => 'customer.example.com']); $provider = app(CloudflareDnsProvider::class); expect($provider->findZones($team->id, 'api.customer.example.com'))->toHaveCount(1) ->and($provider->findZones($team->id, 'app.example.com'))->toHaveCount(2) ->and($provider->findZones($team->id, 'notexample.com'))->toBeEmpty(); }); test('team cloudflare zones are queried once per provider instance', function () { $team = Team::factory()->create(); $otherTeam = Team::factory()->create(); $token = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare']); $otherToken = IntegrationToken::factory()->for($otherTeam)->create(['provider' => 'cloudflare']); DnsProviderZone::factory()->for($token)->create(['name' => 'example.com']); DnsProviderZone::factory()->for($otherToken)->create(['name' => 'other.com']); $provider = app(CloudflareDnsProvider::class); DB::flushQueryLog(); DB::enableQueryLog(); expect($provider->findZones($team->id, 'app.example.com'))->toHaveCount(1); $firstCallQueries = count(DB::getQueryLog()); expect($provider->findZones($team->id, 'api.example.com'))->toHaveCount(1) ->and($provider->findZones($team->id, 'www.example.com'))->toHaveCount(1) ->and(count(DB::getQueryLog()))->toBe($firstCallQueries) ->and($firstCallQueries)->toBeGreaterThan(0); $afterTeamQueries = count(DB::getQueryLog()); expect($provider->findZones($otherTeam->id, 'app.other.com'))->toHaveCount(1) ->and(count(DB::getQueryLog()))->toBeGreaterThan($afterTeamQueries); DB::disableQueryLog(); }); test('a cloudflare record is created and tracked as managed by coolify', function () { $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); Http::fake([ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => []]), 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-1']]), ]); $record = app(CloudflareDnsProvider::class)->createRecord($zone, 'app.example.com', '203.0.113.10'); expect($record->provider_record_id)->toBe('record-1')->and($record->content)->toBe('203.0.113.10') ->and($record->owned)->toBeTrue(); Http::assertSent(fn ($request) => $request->method() === 'POST' && $request->data()['name'] === 'app.example.com' && $request->data()['content'] === '203.0.113.10' && $request->data()['comment'] === $record->ownershipComment()); }); test('cloudflare records are created proxied only when the credential enables proxy mode', function (?array $metadata, bool $proxied) { $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret', 'metadata' => $metadata]); $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); Http::fake([ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => []]), 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-1']]), ]); app(CloudflareDnsProvider::class)->createRecord($zone, 'app.example.com', '203.0.113.10'); Http::assertSent(fn ($request) => $request->method() === 'POST' && $request->data()['proxied'] === $proxied); })->with([ 'default' => [null, false], 'enabled' => [['proxied_dns' => true], true], ]); test('queued dns configuration creates the record and broadcasts completion', function () { Event::fake([DnsRecordConfigurationFinished::class]); $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); Http::fake([ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => []]), 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-1']]), ]); $job = new ConfigureDnsRecordJob( teamId: $token->team_id, zoneId: $zone->id, resourceType: null, resourceId: null, hostname: 'app.example.com', content: '203.0.113.10', ); $job->handle(app(CloudflareDnsProvider::class)); expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeTrue(); Event::assertDispatched(DnsRecordConfigurationFinished::class, fn ($event) => $event->successful && $event->hostname === 'app.example.com'); }); test('queued dns configuration refuses a private record address without calling the provider', function (string $address) { Event::fake([DnsRecordConfigurationFinished::class]); $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); Http::fake(); $job = new ConfigureDnsRecordJob( teamId: $token->team_id, zoneId: $zone->id, resourceType: null, resourceId: null, hostname: 'app.example.com', content: $address, ); $job->handle(app(CloudflareDnsProvider::class)); Http::assertNothingSent(); expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeFalse(); Event::assertDispatched(DnsRecordConfigurationFinished::class, fn ($event) => $event->successful === false && $event->hostname === 'app.example.com' && $event->message === 'The server has no public IP address; add the DNS record manually.'); })->with(['10.0.0.5', '100.100.1.1', '127.0.0.1', '169.254.1.1', 'fd00::5']); test('missing zone throws from handle without broadcasting completion', function () { Event::fake([DnsRecordConfigurationFinished::class]); $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); $job = new ConfigureDnsRecordJob( teamId: $token->team_id, zoneId: 999999, resourceType: null, resourceId: null, hostname: 'app.example.com', content: '203.0.113.10', ); expect(fn () => $job->handle(app(CloudflareDnsProvider::class))) ->toThrow(ModelNotFoundException::class); Event::assertNotDispatched(DnsRecordConfigurationFinished::class); }); test('job failure broadcasts a dns configuration finished event', function () { Event::fake([DnsRecordConfigurationFinished::class]); $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); $job = new ConfigureDnsRecordJob( teamId: $token->team_id, zoneId: 999999, resourceType: 'App\\Models\\Application', resourceId: 42, hostname: 'app.example.com', content: '203.0.113.10', ); $job->failed(new ModelNotFoundException); Event::assertDispatched(DnsRecordConfigurationFinished::class, fn ($event) => $event->successful === false && $event->teamId === $token->team_id && $event->resourceType === 'App\\Models\\Application' && $event->resourceId === 42 && $event->hostname === 'app.example.com' && $event->credential === '' && $event->message === 'The DNS zone is no longer available.'); }); test('an existing matching remote record is tracked without creating a new one', function () { $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); Http::fake([ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([ 'success' => true, 'result' => [[ 'id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', ]], ]), ]); $record = app(CloudflareDnsProvider::class)->createRecord($zone, 'app.example.com', '203.0.113.10'); expect($record->provider_record_id)->toBe('record-1') ->and($record->content)->toBe('203.0.113.10') ->and($record->owned)->toBeFalse() ->and(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeTrue(); Http::assertNotSent(fn ($request) => $request->method() === 'POST'); }); test('queued dns configuration treats a matching existing record as success', function () { Event::fake([DnsRecordConfigurationFinished::class]); $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); Http::fake([ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([ 'success' => true, 'result' => [[ 'id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', ]], ]), ]); $job = new ConfigureDnsRecordJob( teamId: $token->team_id, zoneId: $zone->id, resourceType: null, resourceId: null, hostname: 'app.example.com', content: '203.0.113.10', ); $job->handle(app(CloudflareDnsProvider::class)); expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeTrue(); Event::assertDispatched(DnsRecordConfigurationFinished::class, fn ($event) => $event->successful && $event->hostname === 'app.example.com'); Http::assertNotSent(fn ($request) => $request->method() === 'POST'); }); test('an existing remote record with different content remains a conflict', function () { $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); Http::fake([ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([ 'success' => true, 'result' => [[ 'id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.99', ]], ]), ]); expect(fn () => app(CloudflareDnsProvider::class)->createRecord($zone, 'app.example.com', '203.0.113.10')) ->toThrow(DnsRecordConflictException::class); expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeFalse(); Http::assertNotSent(fn ($request) => $request->method() === 'POST'); }); test('a managed record changed outside coolify is not deleted', function () { $record = ManagedDnsRecord::factory()->owned()->create([ 'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', ]); Http::fake(['https://api.cloudflare.com/client/v4/zones/*/dns_records/record-1' => Http::response([ 'success' => true, 'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.99', 'comment' => $record->ownershipComment()], ])]); expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBe(ManagedDnsDeletionResult::ChangedExternally) ->and($record->fresh())->not->toBeNull(); Http::assertNotSent(fn ($request) => $request->method() === 'DELETE'); }); test('a record coolify did not create is never deleted', function () { $record = ManagedDnsRecord::factory()->create([ 'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', ]); Http::fake(); expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBe(ManagedDnsDeletionResult::NotOwned) ->and($record->fresh())->not->toBeNull(); Http::assertNothingSent(); }); test('an unchanged managed record is deleted from cloudflare and coolify', function () { $record = ManagedDnsRecord::factory()->owned()->create([ 'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', ]); Http::fake(['https://api.cloudflare.com/client/v4/zones/*/dns_records/record-1' => Http::sequence() ->push(['success' => true, 'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => $record->ownershipComment()]]) ->push(['success' => true, 'result' => ['id' => 'record-1']])]); expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBe(ManagedDnsDeletionResult::Deleted) ->and(ManagedDnsRecord::query()->find($record->id))->toBeNull(); }); describe('domain DNS configuration after add', function () { beforeEach(function () { $this->withoutVite(); config()->set('app.maintenance.store', 'array'); Queue::fake(); 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->token = IntegrationToken::factory()->for($this->team)->create([ 'provider' => 'cloudflare', 'name' => 'Production DNS', 'token' => 'secret', 'capabilities' => ['dns'], ]); $this->zone = DnsProviderZone::factory()->for($this->token)->create([ 'name' => 'example.com', 'provider_zone_id' => 'zone-1', ]); }); test('application domains queues a dns record job and marks the row pending', function () { $application = Application::factory()->create([ 'uuid' => (string) Str::uuid(), 'name' => 'DNS App', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'fqdn' => null, 'redirect' => 'both', 'build_pack' => 'nixpacks', ]); $application->settings()->update([ 'is_container_label_readonly_enabled' => true, ]); Livewire::test(Domains::class, ['application' => $application->fresh()]) ->set('newDomain', 'https://app.example.com') ->call('addDomain') ->assertHasNoErrors() ->assertDispatched('success', 'Domain added.') ->assertDispatched('info', 'Adding DNS record for app.example.com.') ->assertSet('showDnsProviderModal', false) ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->contains( fn (array $row): bool => $row['url'] === 'https://app.example.com' && $row['dns_status'] === 'pending' )) ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->contains( fn (array $row): bool => $row['url'] === 'https://www.app.example.com' && $row['dns_status'] === 'pending' )); Queue::assertPushed(ConfigureDnsRecordJob::class, 2); Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'app.example.com' && $job->content === '203.0.113.10' && $job->teamId === $this->team->id && $job->zoneId === $this->zone->id && $job->resourceType === $application->getMorphClass() && (string) $job->resourceId === (string) $application->id); Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'www.app.example.com' && $job->content === '203.0.113.10'); Queue::assertNotPushed(CheckDomainDnsJob::class); }); test('service domains queues a dns record job and marks the row pending', function () { $service = Service::factory()->create([ 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'environment_id' => $this->environment->id, 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", ]); $webApp = ServiceApplication::create([ 'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'web', 'human_name' => 'Web', 'image' => 'nginx:alpine', 'fqdn' => null, ]); Livewire::test(ServiceDomains::class, ['service' => $service->fresh(['applications', 'server'])]) ->set('newServiceApplicationId', $webApp->id) ->set('newDomain', 'https://web.example.com') ->call('addDomain') ->assertHasNoErrors() ->assertDispatched('success', 'Domain added.') ->assertDispatched('info', 'Adding DNS record for web.example.com.') ->assertSet('showDnsProviderModal', false) ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->contains( fn (array $row): bool => $row['url'] === 'https://web.example.com' && $row['dns_status'] === 'pending' )); Queue::assertPushed(ConfigureDnsRecordJob::class, 1); Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'web.example.com' && $job->content === '203.0.113.10' && $job->teamId === $this->team->id && $job->zoneId === $this->zone->id && $job->resourceType === $webApp->getMorphClass() && (string) $job->resourceId === (string) $webApp->id); Queue::assertNotPushed(CheckDomainDnsJob::class); }); test('private server addresses are never queued as dns records', function (string $address) { $this->server->update(['ip' => $address]); $application = Application::factory()->create([ 'uuid' => (string) Str::uuid(), 'name' => 'Private DNS App', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'fqdn' => null, 'redirect' => 'both', 'build_pack' => 'nixpacks', ]); $application->settings()->update(['is_container_label_readonly_enabled' => true]); Livewire::test(Domains::class, ['application' => $application->fresh()]) ->set('newDomain', 'https://app.example.com') ->call('addDomain') ->assertHasNoErrors() ->assertDispatched('warning', 'The server has no public IP address; add the DNS record manually.') ->assertNotDispatched('info') ->assertSet('showDnsProviderModal', false); expect(explode(',', (string) $application->fresh()->fqdn))->toContain('https://app.example.com'); Queue::assertNotPushed(ConfigureDnsRecordJob::class); })->with([ 'rfc1918 10/8' => '10.0.0.5', 'rfc1918 172.16/12' => '172.16.0.5', 'rfc1918 192.168/16' => '192.168.1.5', 'cgnat' => '100.64.0.5', 'tailscale' => '100.100.1.1', 'loopback' => '127.0.0.1', 'link-local' => '169.254.1.1', 'ipv6 unique local' => 'fd00::5', 'ipv6 link-local' => 'fe80::5', 'ipv6 loopback' => '::1', ]); test('service domains never queue a dns record for a private server address', function () { $this->server->update(['ip' => '100.100.1.1']); $service = Service::factory()->create([ 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'environment_id' => $this->environment->id, 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", ]); $webApp = ServiceApplication::create([ 'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'web', 'human_name' => 'Web', 'image' => 'nginx:alpine', 'fqdn' => null, ]); Livewire::test(ServiceDomains::class, ['service' => $service->fresh(['applications', 'server'])]) ->set('newServiceApplicationId', $webApp->id) ->set('newDomain', 'https://web.example.com') ->call('addDomain') ->assertHasNoErrors() ->assertDispatched('warning', 'The server has no public IP address; add the DNS record manually.') ->assertSet('showDnsProviderModal', false); Queue::assertNotPushed(ConfigureDnsRecordJob::class); }); }); test('dns provider action controls declare update authorization against the resource', function () { $dnsMenu = file_get_contents(resource_path('views/livewire/project/shared/cloudflare-autoconfigure.blade.php')); $providerModal = file_get_contents(resource_path('views/livewire/project/shared/dns-provider-management.blade.php')); expect($dnsMenu) ->toContain('$dnsAuthResource = property_exists($this, \'application\') ? $this->application : $this->service') ->toMatch('/]*wire:click="createManagedDnsRecord)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$dnsAuthResource")[^>]*>/'); expect($providerModal) ->toContain('$dnsAuthResource = property_exists($this, \'application\') ? $this->application : $this->service') ->toMatch('/]*submitAction="replaceManagedDnsRecord)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$dnsAuthResource")[^>]*>/') ->toMatch('/]*wire:click="createManagedDnsRecord)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$dnsAuthResource")[^>]*>/'); }); test('removing a domain deletes only the managed dns record for that resource', function () { $this->withoutVite(); config(['app.maintenance.driver' => 'file']); InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate( ['id' => 0], ['id' => 0, 'is_dns_validation_enabled' => false], )); $team = Team::factory()->create(); $user = User::factory()->create(); $team->members()->attach($user->id, ['role' => 'owner']); $this->actingAs($user); session(['currentTeam' => $team]); $server = Server::factory()->create(['team_id' => $team->id, 'ip' => '203.0.113.10']); $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); $destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate( ['server_id' => $server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'], )); $project = Project::factory()->create(['team_id' => $team->id]); $environment = Environment::factory()->create(['project_id' => $project->id]); $application = Application::factory()->create([ 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), 'fqdn' => 'https://app.example.com', 'build_pack' => 'nixpacks', ]); $application->settings()->update(['is_container_label_readonly_enabled' => true]); $otherApplication = Application::factory()->create([ 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), 'fqdn' => 'https://other.example.com', 'build_pack' => 'nixpacks', ]); $token = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare', 'token' => 'secret']); $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); $otherRecord = ManagedDnsRecord::factory()->owned()->create([ 'team_id' => $team->id, 'integration_token_id' => $token->id, 'dns_provider_zone_id' => $zone->id, 'provider_record_id' => 'record-other', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', ]); $otherRecord->addReference($otherApplication); $ownRecord = ManagedDnsRecord::factory()->owned()->create([ 'team_id' => $team->id, 'integration_token_id' => $token->id, 'dns_provider_zone_id' => $zone->id, 'provider_record_id' => 'record-own', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', ]); $ownRecord->addReference($application); Http::fake([ 'https://api.cloudflare.com/client/v4/zones/*/dns_records/record-own' => Http::sequence() ->push(['success' => true, 'result' => ['id' => 'record-own', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => $ownRecord->ownershipComment()]]) ->push(['success' => true, 'result' => ['id' => 'record-own']]), 'https://api.cloudflare.com/client/v4/zones/*/dns_records/record-other' => Http::response([ 'success' => true, 'result' => ['id' => 'record-other', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10'], ]), ]); Livewire::test(Domains::class, ['application' => $application->fresh()]) ->call('removeDomain', 0, '', ['deleteManagedDns']) ->assertDispatched('success'); expect(ManagedDnsRecord::query()->find($ownRecord->id))->toBeNull() ->and(ManagedDnsRecord::query()->find($otherRecord->id))->not->toBeNull(); Http::assertSent(fn ($request) => str_contains($request->url(), 'record-own') && $request->method() === 'DELETE'); Http::assertNotSent(fn ($request) => str_contains($request->url(), 'record-other')); }); test('replaceRecord updates the cloudflare record when the conflict still matches', function () { $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); Http::fake([ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/record-1' => Http::response([ 'success' => true, 'result' => ['id' => 'record-1'], ]), 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([ 'success' => true, 'result' => [['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50']], ]), ]); $record = app(CloudflareDnsProvider::class)->replaceRecord( $zone, 'record-1', 'app.example.com', '203.0.113.10', expectedCurrent: '198.51.100.50', ); expect($record->provider_record_id)->toBe('record-1')->and($record->content)->toBe('203.0.113.10') ->and($record->owned)->toBeFalse(); Http::assertSent(fn ($request) => $request->method() === 'PATCH' && str_ends_with($request->url(), '/dns_records/record-1') && $request->data() === ['content' => '203.0.113.10']); }); test('replaceRecord rejects a stale or tampered conflict without updating dns', function (string $recordId, string $current) { $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); Http::fake([ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([ 'success' => true, 'result' => [['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50']], ]), 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/*' => Http::response(['success' => true, 'result' => ['id' => 'record-other']]), ]); expect(fn () => app(CloudflareDnsProvider::class)->replaceRecord( $zone, $recordId, 'app.example.com', '203.0.113.10', expectedCurrent: $current, ))->toThrow(RuntimeException::class, 'The DNS conflict is no longer available. Check the record again.'); Http::assertNotSent(fn ($request) => in_array($request->method(), ['PUT', 'PATCH'], true)); })->with([ 'wrong record id' => ['record-other', '198.51.100.50'], 'wrong current value' => ['record-1', '203.0.113.99'], ]); test('replacing a managed dns record uses the server ip and live cloudflare record id', function () { ['application' => $application, 'zone' => $zone] = prepareManagedDnsApplication(); Http::fake([ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/record-1' => Http::response([ 'success' => true, 'result' => ['id' => 'record-1'], ]), 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([ 'success' => true, 'result' => [['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50']], ]), ]); Livewire::test(Domains::class, ['application' => $application->fresh()]) ->call('createManagedDnsRecord', 'app.example.com', $zone->id) ->assertSet('dnsProviderConflicts', ['app.example.com|'.$zone->id => [ 'record_id' => 'record-1', 'current' => '198.51.100.50', 'proposed' => '203.0.113.10', ]]) ->call('replaceManagedDnsRecord', 'app.example.com', $zone->id) ->assertDispatched('success', 'DNS record replaced for app.example.com.'); Http::assertSent(fn ($request) => $request->method() === 'PATCH' && str_ends_with($request->url(), '/dns_records/record-1') && $request->data() === ['content' => '203.0.113.10']); expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->where('content', '203.0.113.10')->where('owned', false)->exists())->toBeTrue(); }); test('dns provider state cannot be changed from the browser', function (string $property, mixed $value) { ['application' => $application] = prepareManagedDnsApplication(); Livewire::test(Domains::class, ['application' => $application->fresh()])->set($property, $value); })->with([ 'server ip' => ['serverIp', '6.6.6.6'], 'configured server ip' => ['serverIpConfigured', '6.6.6.6'], 'conflicts' => ['dnsProviderConflicts', ['app.example.com|1' => ['record_id' => 'record-other', 'current' => 'x', 'proposed' => '6.6.6.6']]], 'proposals' => ['dnsProviderProposals', [['hostname' => 'www.victim.com', 'zone_id' => 1]]], ])->throws(CannotUpdateLockedPropertyException::class); test('dns records use the zones of the resource team, not the session team', function () { ['application' => $application] = prepareManagedDnsApplication(); $otherTeam = Team::factory()->create(); $otherTeam->members()->attach(auth()->id(), ['role' => 'member']); $otherToken = IntegrationToken::factory()->for($otherTeam)->create(['provider' => 'cloudflare', 'token' => 'other-secret']); $otherZone = DnsProviderZone::factory()->for($otherToken)->create(['provider_zone_id' => 'zone-other', 'name' => 'victim.com']); Http::fake(); $component = Livewire::test(Domains::class, ['application' => $application->fresh()]); // The user switches to the other team in another browser tab. session(['currentTeam' => $otherTeam]); $component->call('createManagedDnsRecord', 'www.victim.com', $otherZone->id) ->assertDispatched('error', 'No connected DNS provider or public server IP is available for this domain.'); Http::assertNothingSent(); }); test('dns records point only at an address of the resource server in a zone of the hostname', function (string $hostname, ?string $content) { ['application' => $application, 'zone' => $zone] = prepareManagedDnsApplication(); Http::fake(); Livewire::test(Domains::class, ['application' => $application->fresh()]) ->call('createManagedDnsRecord', $hostname, $zone->id, $content) ->assertDispatched('error', 'No connected DNS provider or public server IP is available for this domain.'); Http::assertNothingSent(); })->with([ 'another ip' => ['app.example.com', '6.6.6.6'], 'hostname outside the zone' => ['www.other.org', null], ]); test('a dns record is never created for a private server address', function (string $address, ?string $content) { ['application' => $application, 'zone' => $zone] = prepareManagedDnsApplication(); Server::query()->whereKey($application->destination->server_id)->update(['ip' => $address]); Http::fake(); Livewire::test(Domains::class, ['application' => $application->fresh()]) ->call('createManagedDnsRecord', 'app.example.com', $zone->id, $content) ->assertDispatched('error', 'The server has no public IP address; add the DNS record manually.'); Http::assertNothingSent(); expect(ManagedDnsRecord::query()->exists())->toBeFalse(); })->with([ 'private ipv4' => ['10.0.0.5', null], 'private ipv4 requested explicitly' => ['10.0.0.5', '10.0.0.5'], 'tailscale cgnat' => ['100.100.1.1', '100.100.1.1'], 'ipv6 unique local' => ['fd00::5', 'fd00::5'], ]); test('replacing a conflicting dns record is refused once the server has no public address', function () { ['application' => $application, 'zone' => $zone] = prepareManagedDnsApplication(); Http::fake([ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([ 'success' => true, 'result' => [['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50']], ]), ]); $component = Livewire::test(Domains::class, ['application' => $application->fresh()]) ->call('createManagedDnsRecord', 'app.example.com', $zone->id); Server::query()->whereKey($application->destination->server_id)->update(['ip' => '192.168.1.5']); $component->call('refreshDomains') ->call('replaceManagedDnsRecord', 'app.example.com', $zone->id) ->assertDispatched('error', 'The server has no public IP address; add the DNS record manually.'); Http::assertNotSent(fn ($request) => $request->method() === 'PATCH'); }); /** * @return array{application: Application, zone: DnsProviderZone} */ function prepareManagedDnsApplication(): array { test()->withoutVite(); config(['app.maintenance.driver' => 'file']); InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate( ['id' => 0], ['id' => 0, 'is_dns_validation_enabled' => false], )); $team = Team::factory()->create(); $user = User::factory()->create(); $team->members()->attach($user->id, ['role' => 'owner']); test()->actingAs($user); session(['currentTeam' => $team]); $server = Server::factory()->create(['team_id' => $team->id, 'ip' => '203.0.113.10']); $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); $destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate( ['server_id' => $server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'], )); $project = Project::factory()->create(['team_id' => $team->id]); $environment = Environment::factory()->create(['project_id' => $project->id]); $application = Application::factory()->create([ 'environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), 'fqdn' => 'https://app.example.com', 'build_pack' => 'nixpacks', ]); $application->settings()->update(['is_container_label_readonly_enabled' => true]); $token = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare', 'token' => 'secret']); $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); return ['application' => $application, 'zone' => $zone]; } function dnsRecordFinishedPayload(Model $resource, int $teamId, string $hostname = 'app.example.com'): array { return [ 'teamId' => $teamId, 'resourceType' => $resource->getMorphClass(), 'resourceId' => $resource->getKey(), 'hostname' => $hostname, 'successful' => true, 'credential' => 'Production DNS', 'message' => "DNS record added for {$hostname}.", ]; } function actingAsDnsTeamMember(Team $team): void { $member = User::factory()->create(); $team->members()->attach($member->id, ['role' => 'member']); test()->actingAs($member); session(['currentTeam' => $team]); } test('a dns record completion event marks the domain managed for a team admin', function () { ['application' => $application] = prepareManagedDnsApplication(); $teamId = $application->team()->id; Livewire::test(Domains::class, ['application' => $application->fresh()]) ->call('dnsRecordConfigurationFinished', dnsRecordFinishedPayload($application, $teamId)) ->assertDispatched('success', 'DNS record added for app.example.com.'); expect(collect($application->fresh()->domain_dns_statuses)->pluck('status')->all())->toContain('ok'); }); test('a team member cannot mark a domain managed with a forged dns completion event', function () { ['application' => $application] = prepareManagedDnsApplication(); $teamId = $application->team()->id; $before = $application->fresh()->domain_dns_statuses; actingAsDnsTeamMember($application->team()); Livewire::test(Domains::class, ['application' => $application->fresh()]) ->call('dnsRecordConfigurationFinished', dnsRecordFinishedPayload($application, $teamId)) ->assertNotDispatched('success'); expect($application->fresh()->domain_dns_statuses)->toBe($before); }); test('a dns completion event of another team or a malformed event is ignored', function (string $case) { ['application' => $application] = prepareManagedDnsApplication(); $before = $application->fresh()->domain_dns_statuses; $payload = $case === 'other team' ? dnsRecordFinishedPayload($application, Team::factory()->create()->id) : ['hostname' => 'app.example.com', 'successful' => true]; Livewire::test(Domains::class, ['application' => $application->fresh()]) ->call('dnsRecordConfigurationFinished', $payload) ->assertNotDispatched('success'); expect($application->fresh()->domain_dns_statuses)->toBe($before); })->with(['other team', 'missing fields']); test('a team member cannot mark a service domain managed with a forged dns completion event', function () { ['application' => $application] = prepareManagedDnsApplication(); $team = $application->team(); $service = Service::factory()->create([ 'server_id' => $application->destination->server->id, 'destination_id' => $application->destination_id, 'destination_type' => $application->destination_type, 'environment_id' => $application->environment_id, 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", ]); $webApp = ServiceApplication::create([ 'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'web', 'human_name' => 'Web', 'image' => 'nginx:alpine', 'fqdn' => 'https://web.example.com', ]); actingAsDnsTeamMember($team); Livewire::test(ServiceDomains::class, ['service' => $service->fresh(['applications', 'server'])]) ->call('dnsRecordConfigurationFinished', dnsRecordFinishedPayload($webApp, $team->id, 'web.example.com')) ->assertNotDispatched('success'); expect($webApp->fresh()->domain_dns_statuses)->toBeEmpty(); }); test('cloudflare dns reads are retried after a connection failure but record creation is not', function () { $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']); $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); Http::fake([ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::sequence() ->pushFailedConnection() ->push(['success' => true, 'result' => []]), 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::failedConnection(), ]); expect(fn () => app(CloudflareDnsProvider::class)->createRecord($zone, 'app.example.com', '203.0.113.10')) ->toThrow(ConnectionException::class); Http::assertSequencesAreEmpty(); expect(collect(Http::recorded())->filter(fn (array $pair) => $pair[0]->method() === 'POST'))->toHaveCount(1); expect(ManagedDnsRecord::query()->count())->toBe(0); });