mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Allow validated host file and directory mounts outside resource directories while protecting external paths from deletion. Clean up cancelled deployment containers and clear tracked process IDs. Preserve legacy KeyDB and Dragonfly password parsing, accept server backup paths without extension restrictions, and allow Compose variable proxy ports.
915 lines
43 KiB
PHP
915 lines
43 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\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('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();
|
|
});
|