mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 01:54:37 +08:00
952 lines
45 KiB
PHP
952 lines
45 KiB
PHP
<?php
|
|
|
|
use App\Enums\ManagedDnsDeletionResult;
|
|
use App\Events\DnsRecordConfigurationFinished;
|
|
use App\Exceptions\DnsRecordConflictException;
|
|
use App\Jobs\CheckDomainDnsJob;
|
|
use App\Jobs\ConfigureDnsRecordJob;
|
|
use App\Livewire\Project\Application\Domains;
|
|
use App\Livewire\Project\Service\Domains as ServiceDomains;
|
|
use App\Models\Application;
|
|
use App\Models\DnsProviderZone;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\IntegrationToken;
|
|
use App\Models\ManagedDnsRecord;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Services\Dns\CloudflareDnsProvider;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
test('cloudflare automatic dns is enabled by default and can be disabled per credential', function () {
|
|
$defaultToken = IntegrationToken::factory()->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('/<x-forms\.button(?=[^>]*wire:click="createManagedDnsRecord)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$dnsAuthResource")[^>]*>/');
|
|
|
|
expect($providerModal)
|
|
->toContain('$dnsAuthResource = property_exists($this, \'application\') ? $this->application : $this->service')
|
|
->toMatch('/<x-modal-confirmation(?=[^>]*submitAction="replaceManagedDnsRecord)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$dnsAuthResource")[^>]*>/')
|
|
->toMatch('/<x-forms\.button(?=[^>]*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);
|
|
});
|