withoutVite(); config()->set('app.maintenance.store', 'array'); config()->set('app.id', 'test-instance'); 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]); $this->server = Server::factory()->create(['team_id' => $this->team->id, 'ip' => '203.0.113.10']); $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); $this->destination = StandaloneDocker::withoutEvents(fn () => 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', 'token' => 'secret']); $this->zone = DnsProviderZone::factory()->for($this->token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); }); test('a created record carries the ownership comment and is owned', function () { fakeCloudflareDns(); $application = createDnsTestApplication($this, 'https://app.example.com'); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); expect($record->owned)->toBeTrue() ->and($record->ownershipComment())->toBe("managed-by: coolify test-instance/{$record->uuid}") ->and($record->references()->where('resource_id', $application->id)->exists())->toBeTrue(); Http::assertSent(fn (Request $request) => $request->method() === 'POST' && $request->data()['comment'] === $record->ownershipComment() && $request->data()['proxied'] === false); }); test('an adopted hand-made record is never deleted when the domain is removed', function () { $cloudflare = fakeCloudflareDns([[ 'id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'proxied' => true, 'ttl' => 300, 'comment' => 'hand made', ]]); $application = createDnsTestApplication($this, 'https://app.example.com'); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); expect($record->owned)->toBeFalse()->and($record->references()->count())->toBe(1); Livewire::test(Domains::class, ['application' => $application]) ->call('removeDomain', 0, '', ['deleteManagedDns']) ->assertDispatched('success'); expect(sentDnsRequests('DELETE'))->toBe(0) ->and(sentDnsRequests('POST'))->toBe(0) ->and(sentDnsRequests('PATCH'))->toBe(0) ->and($cloudflare['records'])->toHaveKey('record-1') ->and(ManagedDnsRecord::query()->find($record->id))->toBeNull(); }); test('replacing a hand-made record keeps proxied, ttl and comment and never deletes it later', function () { $cloudflare = fakeCloudflareDns([[ 'id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50', 'proxied' => true, 'ttl' => 300, 'comment' => 'hand made', ]]); $application = createDnsTestApplication($this, 'https://app.example.com'); $record = app(CloudflareDnsProvider::class)->replaceRecord( $this->zone, 'record-1', 'app.example.com', '203.0.113.10', $application, expectedCurrent: '198.51.100.50', ); Http::assertSent(fn (Request $request) => $request->method() === 'PATCH' && str_ends_with($request->url(), '/dns_records/record-1') && $request->data() === ['content' => '203.0.113.10']); expect($record->owned)->toBeFalse() ->and($cloudflare['records']['record-1'])->toMatchArray([ 'content' => '203.0.113.10', 'proxied' => true, 'ttl' => 300, 'comment' => 'hand made', ]); Livewire::test(Domains::class, ['application' => $application]) ->call('removeDomain', 0, '', ['deleteManagedDns']) ->assertDispatched('success'); expect(sentDnsRequests('DELETE'))->toBe(0) ->and($cloudflare['records'])->toHaveKey('record-1'); }); test('an owned record is kept while another resource references it and deleted after the last reference', function () { $cloudflare = fakeCloudflareDns(); $first = createDnsTestApplication($this, 'https://app.example.com'); $second = createDnsTestApplication($this, 'https://app.example.com/api'); $provider = app(CloudflareDnsProvider::class); $record = $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10', $first); $adopted = $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10', $second); expect($adopted->id)->toBe($record->id) ->and($adopted->fresh()->owned)->toBeTrue() ->and($record->references()->count())->toBe(2) ->and(sentDnsRequests('POST'))->toBe(1); Livewire::test(Domains::class, ['application' => $first]) ->call('removeDomain', 0, '', ['deleteManagedDns']) ->assertDispatched('success'); expect(sentDnsRequests('DELETE'))->toBe(0) ->and($record->fresh())->not->toBeNull() ->and($record->references()->pluck('resource_id')->all())->toBe([$second->id]); Livewire::test(Domains::class, ['application' => $second]) ->call('removeDomain', 0, '', ['deleteManagedDns']) ->assertDispatched('success'); expect(sentDnsRequests('DELETE'))->toBe(1) ->and($cloudflare['records'])->toBeEmpty() ->and($record->fresh())->toBeNull(); }); test('another application of the same team still using the hostname prevents deletion and takes over the reference', function () { $cloudflare = fakeCloudflareDns(); $application = createDnsTestApplication($this, 'https://app.example.com'); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); $otherApplication = createDnsTestApplication($this, 'http://other.example.org,https://APP.example.com:8443'); Livewire::test(Domains::class, ['application' => $application]) ->call('removeDomain', 0, '', ['deleteManagedDns']) ->assertDispatched('success'); expect(sentDnsRequests('DELETE'))->toBe(0) ->and($cloudflare['records'])->toHaveCount(1) ->and($record->fresh())->not->toBeNull() ->and($record->references()->pluck('resource_id')->all())->toBe([$otherApplication->id]); $otherApplication->delete(); expect(sentDnsRequests('DELETE'))->toBe(1) ->and($cloudflare['records'])->toBeEmpty() ->and($record->fresh())->toBeNull(); }); test('an application of another team using the hostname prevents deletion', function () { $cloudflare = fakeCloudflareDns(); $application = createDnsTestApplication($this, 'https://app.example.com'); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); $otherTeam = Team::factory()->create(); $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); Application::factory()->create([ 'environment_id' => $otherEnvironment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'fqdn' => 'https://app.example.com', 'build_pack' => 'nixpacks', ]); Livewire::test(Domains::class, ['application' => $application]) ->call('removeDomain', 0, '', ['deleteManagedDns']) ->assertDispatched('success'); expect(sentDnsRequests('DELETE'))->toBe(0) ->and($cloudflare['records'])->toHaveCount(1) ->and(ManagedDnsRecord::query()->find($record->id))->toBeNull(); }); test('a hostname still used by a compose application or preview prevents deletion', function (string $usage) { fakeCloudflareDns(); $application = createDnsTestApplication($this, 'https://app.example.com'); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); $other = createDnsTestApplication($this, 'https://unrelated.example.org'); if ($usage === 'compose') { $other->update(['docker_compose_domains' => json_encode(['web' => ['domain' => 'https://app.example.com']])]); } else { ApplicationPreview::query()->create([ 'application_id' => $other->id, 'pull_request_id' => 7, 'pull_request_html_url' => 'https://github.com/x/y/pull/7', 'fqdn' => 'https://app.example.com', ]); } Livewire::test(Domains::class, ['application' => $application]) ->call('removeDomain', 0, '', ['deleteManagedDns']) ->assertDispatched('success'); expect(sentDnsRequests('DELETE'))->toBe(0)->and($record->fresh())->not->toBeNull(); })->with(['compose', 'preview']); test('an owned record whose remote comment was changed or removed is not deleted', function (?string $comment) { $cloudflare = fakeCloudflareDns(); $application = createDnsTestApplication($this, 'https://app.example.com'); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); $records = $cloudflare['records']; $records[$record->provider_record_id]['comment'] = $comment; $cloudflare['records'] = $records; Livewire::test(Domains::class, ['application' => $application]) ->call('removeDomain', 0, '', ['deleteManagedDns']) ->assertDispatched('warning', 'The domain was removed, but its DNS record changed externally and was left untouched.'); expect(sentDnsRequests('DELETE'))->toBe(0) ->and($cloudflare['records'])->toHaveKey($record->provider_record_id) ->and($record->fresh())->toBeNull(); })->with([ 'removed' => [null], 'changed' => ['production record'], 'other coolify record' => ['managed-by: coolify test-instance/someotherrecord'], ]); test('removing a domain without deleting dns forgets the reference and keeps the remote record', function () { $cloudflare = fakeCloudflareDns(); $application = createDnsTestApplication($this, 'https://app.example.com'); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); Livewire::test(Domains::class, ['application' => $application]) ->call('removeDomain', 0, '', []) ->assertDispatched('success'); expect(sentDnsRequests('DELETE'))->toBe(0) ->and($cloudflare['records'])->toHaveCount(1) ->and($record->fresh())->toBeNull(); }); test('removing one of two urls with the same hostname keeps the reference', function () { $cloudflare = fakeCloudflareDns(); $application = createDnsTestApplication($this, 'https://app.example.com,http://app.example.com:8080'); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); Livewire::test(Domains::class, ['application' => $application]) ->call('removeDomain', 0, '', ['deleteManagedDns']) ->assertDispatched('success'); expect(sentDnsRequests('DELETE'))->toBe(0) ->and($record->fresh())->not->toBeNull() ->and($record->references()->pluck('resource_id')->all())->toBe([$application->id]); }); test('an existing round-robin record with the wanted content satisfies the request', function () { fakeCloudflareDns([ ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50', 'comment' => null], ['id' => 'record-2', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => null], ]); $application = createDnsTestApplication($this, 'https://app.example.com'); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); expect($record->provider_record_id)->toBe('record-2')->and($record->owned)->toBeFalse(); expect(sentDnsRequests('POST'))->toBe(0); }); test('several round-robin records without the wanted content are not replaced', function () { fakeCloudflareDns([ ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50'], ['id' => 'record-2', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.51'], ]); $provider = app(CloudflareDnsProvider::class); expect(fn () => $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10')) ->toThrow(RuntimeException::class, 'Several DNS records already exist for app.example.com'); expect(fn () => $provider->replaceRecord($this->zone, 'record-1', 'app.example.com', '203.0.113.10', expectedCurrent: '198.51.100.50')) ->toThrow(RuntimeException::class); expect(sentDnsRequests('POST') + sentDnsRequests('PATCH') + sentDnsRequests('PUT'))->toBe(0); }); test('deleting an application removes its owned dns records', function () { $cloudflare = fakeCloudflareDns(); $application = createDnsTestApplication($this, 'https://app.example.com'); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); $application->delete(); expect(sentDnsRequests('DELETE'))->toBe(1) ->and($cloudflare['records'])->toBeEmpty() ->and($record->fresh())->toBeNull(); }); test('deleting a service application or preview removes its owned dns records', function (string $kind) { $cloudflare = fakeCloudflareDns(); if ($kind === 'service application') { $service = Service::factory()->create([ 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'environment_id' => $this->environment->id, ]); $resource = ServiceApplication::create([ 'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'web', 'image' => 'nginx:alpine', 'fqdn' => 'https://app.example.com', ]); } else { $application = createDnsTestApplication($this, 'https://main.example.org'); $resource = ApplicationPreview::query()->create([ 'application_id' => $application->id, 'pull_request_id' => 3, 'pull_request_html_url' => 'https://github.com/x/y/pull/3', 'fqdn' => 'https://app.example.com', ]); } $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $resource); $resource->delete(); expect(sentDnsRequests('DELETE'))->toBe(1) ->and($cloudflare['records'])->toBeEmpty() ->and($record->fresh())->toBeNull(); })->with(['service application', 'preview']); test('deleting an application also releases the references of its previews', function () { $cloudflare = fakeCloudflareDns(); $application = createDnsTestApplication($this, 'https://main.example.org'); $preview = ApplicationPreview::query()->create([ 'application_id' => $application->id, 'pull_request_id' => 4, 'pull_request_html_url' => 'https://github.com/x/y/pull/4', 'fqdn' => 'https://pr-4.example.com', ]); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'pr-4.example.com', '203.0.113.10', $preview); $application->forceDelete(); expect($cloudflare['records'])->toBeEmpty()->and($record->fresh())->toBeNull(); }); test('resource deletion succeeds and keeps the record when cloudflare is unreachable', function () { fakeCloudflareDns(); $application = createDnsTestApplication($this, 'https://app.example.com'); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application); Http::fake(fn () => throw new ConnectionException('Cloudflare is unreachable.')); $application->forceDelete(); expect(Application::withTrashed()->find($application->id))->toBeNull() ->and($record->fresh())->not->toBeNull() ->and($record->references()->count())->toBe(1); }); test('deleting a resource dispatches the release job only when it references dns records', function () { Queue::fake(); fakeCloudflareDns(); $withRecord = createDnsTestApplication($this, 'https://app.example.com'); $withoutRecord = createDnsTestApplication($this, 'https://plain.example.com'); app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $withRecord); $withoutRecord->delete(); Queue::assertNotPushed(ReleaseManagedDnsRecordsJob::class); $withRecord->delete(); Queue::assertPushed(ReleaseManagedDnsRecordsJob::class, fn (ReleaseManagedDnsRecordsJob $job): bool => $job->resourceType === $withRecord->getMorphClass() && (string) $job->resourceId === (string) $withRecord->id); }); test('service domain removal releases only the removed service application reference', function () { $cloudflare = fakeCloudflareDns(); $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 api:\n image: nginx:alpine\n", ]); $web = ServiceApplication::create([ 'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'web', 'image' => 'nginx:alpine', 'fqdn' => 'https://app.example.com', ]); $api = ServiceApplication::create([ 'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'api', 'image' => 'nginx:alpine', 'fqdn' => 'https://app.example.com/api', ]); $provider = app(CloudflareDnsProvider::class); $record = $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10', $web); $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10', $api); Livewire::test(ServiceDomains::class, ['service' => $service->fresh(['applications', 'server'])]) ->call('removeDomainByKey', hash('sha256', 'https://app.example.com/api|'.$api->id), '', ['deleteManagedDns']) ->assertDispatched('success'); expect(sentDnsRequests('DELETE'))->toBe(0) ->and($cloudflare['records'])->toHaveCount(1) ->and($record->references()->pluck('resource_id')->all())->toBe([$web->id]); }); test('releasing a hostname never touches records of another team', function () { $cloudflare = fakeCloudflareDns([[ 'id' => 'record-foreign', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => null, ]]); $application = createDnsTestApplication($this, 'https://app.example.com'); $otherToken = IntegrationToken::factory()->for(Team::factory()->create())->create(['provider' => 'cloudflare', 'token' => 'other']); $otherZone = DnsProviderZone::factory()->for($otherToken)->create(['provider_zone_id' => 'zone-2', 'name' => 'example.com']); $foreignRecord = ManagedDnsRecord::factory()->owned()->create([ 'dns_provider_zone_id' => $otherZone->id, 'provider_record_id' => 'record-foreign', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', ]); $foreignRecord->addReference($application); Livewire::test(Domains::class, ['application' => $application]) ->call('removeDomain', 0, '', ['deleteManagedDns']) ->assertDispatched('success'); expect(Http::recorded())->toBeEmpty() ->and($cloudflare['records'])->toHaveKey('record-foreign') ->and($foreignRecord->fresh())->not->toBeNull() ->and($foreignRecord->references()->count())->toBe(1); }); test('an existing AAAA record written in another IPv6 notation satisfies the request', function () { fakeCloudflareDns([ ['id' => 'record-1', 'type' => 'AAAA', 'name' => 'app.example.com', 'content' => '2001:db8::1', 'comment' => null], ]); $application = createDnsTestApplication($this, 'https://app.example.com'); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '2001:0DB8:0:0::1', $application); expect($record->provider_record_id)->toBe('record-1')->and($record->owned)->toBeFalse(); expect(sentDnsRequests('POST'))->toBe(0); }); test('an owned AAAA record is deleted when the provider returns it in canonical IPv6 notation', function () { $cloudflare = fakeCloudflareDns(); $application = createDnsTestApplication($this, 'https://app.example.com'); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '2001:0db8:0000:0000:0000:0000:0000:0001', $application); $records = $cloudflare['records']; $records[$record->provider_record_id]['content'] = '2001:db8::1'; $cloudflare['records'] = $records; $result = app(CloudflareDnsProvider::class)->deleteRecord($record->fresh(), $application); expect($result)->toBe(ManagedDnsDeletionResult::Deleted) ->and($cloudflare['records'])->not->toHaveKey($record->provider_record_id); });