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');
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& $request->data()['name'] === 'app.example.com'
&& $request->data()['content'] === '203.0.113.10');
});
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('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(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()->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'],
])]);
expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBeFalse()->and($record->fresh())->not->toBeNull();
});
test('an unchanged managed record is deleted from cloudflare and coolify', function () {
$record = ManagedDnsRecord::factory()->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']])
->push(['success' => true, 'result' => ['id' => 'record-1']])]);
expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBeTrue()
->and(ManagedDnsRecord::query()->find($record->id))->toBeNull();
});
test('dns provider modal view always has a single root element', function () {
$providerModal = file_get_contents(resource_path('views/livewire/project/shared/dns-provider-management.blade.php'));
expect(ltrim($providerModal))->toStartWith('
')
->and($providerModal)->toContain('@if ($showDnsProviderModal)');
});
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('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()->create([
'team_id' => $team->id,
'integration_token_id' => $token->id,
'dns_provider_zone_id' => $zone->id,
'resource_type' => $otherApplication->getMorphClass(),
'resource_id' => $otherApplication->getKey(),
'provider_record_id' => 'record-other',
'type' => 'A',
'name' => 'app.example.com',
'content' => '203.0.113.10',
]);
$ownRecord = ManagedDnsRecord::factory()->create([
'team_id' => $team->id,
'integration_token_id' => $token->id,
'dns_provider_zone_id' => $zone->id,
'resource_type' => $application->getMorphClass(),
'resource_id' => $application->getKey(),
'provider_record_id' => 'record-own',
'type' => 'A',
'name' => 'app.example.com',
'content' => '203.0.113.10',
]);
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']])
->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');
Http::assertSent(fn ($request) => $request->method() === 'PUT'
&& str_ends_with($request->url(), '/dns_records/record-1')
&& $request->data()['name'] === 'app.example.com'
&& $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) => $request->method() === 'PUT');
})->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()])
->set('dnsProviderConflicts', [
'app.example.com|'.$zone->id => [
'record_id' => 'record-1',
'current' => '198.51.100.50',
'proposed' => '198.51.100.1',
],
])
->call('replaceManagedDnsRecord', 'app.example.com', $zone->id)
->assertDispatched('success', 'DNS record replaced for app.example.com.');
Http::assertSent(fn ($request) => $request->method() === 'PUT'
&& 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')->exists())->toBeTrue();
});
test('replacing a managed dns record ignores a tampered conflict record id', 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']],
]),
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/*' => Http::response([
'success' => true, 'result' => ['id' => 'record-other'],
]),
]);
Livewire::test(Domains::class, ['application' => $application->fresh()])
->set('dnsProviderConflicts', [
'app.example.com|'.$zone->id => [
'record_id' => 'record-other',
'current' => '198.51.100.50',
'proposed' => '198.51.100.1',
],
])
->call('replaceManagedDnsRecord', 'app.example.com', $zone->id)
->assertDispatched('error', 'The DNS conflict is no longer available. Check the record again.')
->assertSet('dnsProviderConflicts', []);
Http::assertNotSent(fn ($request) => $request->method() === 'PUT');
expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeFalse();
});
/**
* @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];
}