set('app.maintenance.store', 'array'); config()->set('app.id', 'test-instance'); InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0, 'is_api_enabled' => true])); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); $this->member = User::factory()->create(); $this->team->members()->attach($this->member->id, ['role' => 'member']); session(['currentTeam' => $this->team]); $this->server = Server::factory()->create(['team_id' => $this->team->id, 'ip' => '203.0.113.10']); $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); $this->environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]); $this->application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'fqdn' => 'https://app.example.com', ]); $this->token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'token' => 'cf-secret-value', 'capabilities' => ['dns']]); $this->zone = DnsProviderZone::factory()->for($this->token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']); }); afterEach(function () { Server::flushIdentityMap(); }); function dnsApiToken(User $user, Team $team, array $abilities = ['*']): string { $plainTextToken = Str::random(40); $token = $user->tokens()->create([ 'name' => 'dns-api-'.Str::random(6), 'token' => hash('sha256', $plainTextToken), 'abilities' => $abilities, 'team_id' => $team->id, ]); return $token->getKey().'|'.$plainTextToken; } /** * Fakes the Cloudflare DNS records API of zone-1 with the given existing records. * * @param array> $records */ function dnsApiFakeCloudflare(array $records = []): void { Http::fake([ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => $records]), 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/*' => Http::response(['success' => true, 'result' => ['id' => 'record-old']]), 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-new']]), ]); } function dnsApiService(object $test, string $fqdn): ServiceApplication { $service = Service::factory()->create([ 'server_id' => $test->server->id, 'destination_id' => $test->destination->id, 'destination_type' => $test->destination->getMorphClass(), 'environment_id' => $test->environment->id, 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", ]); return ServiceApplication::create([ 'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'web', 'human_name' => 'Web', 'image' => 'nginx:alpine', 'fqdn' => $fqdn, ]); } describe('integration tokens', function () { test('an owner creates a cloudflare token with its zones and options', function () { Http::fake([ 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response(['result' => ['status' => 'active']]), 'https://api.cloudflare.com/client/v4/zones/zone-9/dns_records*' => Http::response(['success' => true, 'result' => []]), 'https://api.cloudflare.com/client/v4/accounts/account-1/cfd_tunnel*' => Http::response(['success' => true, 'result' => []]), 'https://api.cloudflare.com/client/v4/zones*' => Http::response([ 'success' => true, 'result' => [['id' => 'zone-9', 'name' => 'example.org', 'account' => ['id' => 'account-1', 'name' => 'Main']]], 'result_info' => ['total_pages' => 1], ]), ]); $uuid = $this->withToken(dnsApiToken($this->user, $this->team))->postJson('/api/v1/security/integration-tokens', [ 'provider' => 'cloudflare', 'name' => 'Cloudflare', 'token' => 'new-cf-secret', 'capabilities' => ['dns', 'tunnel'], 'automatic_dns' => false, 'proxied_dns' => true, ])->assertCreated()->json('uuid'); $token = IntegrationToken::query()->where('uuid', $uuid)->firstOrFail(); expect($token->team_id)->toBe($this->team->id) ->and($token->capabilities)->toBe(['dns', 'tunnel']) ->and($token->automaticDnsEnabled())->toBeFalse() ->and($token->proxiedDnsEnabled())->toBeTrue() ->and($token->dnsZones()->pluck('name')->all())->toBe(['example.org']); }); test('a cloudflare token is not stored when cloudflare rejects it', function () { Http::fake(['https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([], 401)]); $this->withToken(dnsApiToken($this->user, $this->team))->postJson('/api/v1/security/integration-tokens', [ 'provider' => 'cloudflare', 'name' => 'Rejected', 'token' => 'bad', ])->assertStatus(400)->assertJson(['message' => 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.']); expect(IntegrationToken::query()->where('name', 'Rejected')->exists())->toBeFalse(); }); test('a tunnel capability without dns is rejected before cloudflare is called', function () { Http::fake(); $this->withToken(dnsApiToken($this->user, $this->team))->postJson('/api/v1/security/integration-tokens', [ 'provider' => 'cloudflare', 'name' => 'Tunnel', 'token' => 'x', 'capabilities' => ['tunnel'], ])->assertUnprocessable()->assertJsonPath('errors.capabilities.0', 'Cloudflare Tunnel also needs the DNS capability.'); Http::assertNothingSent(); }); test('list and get return the token settings but never the secret', function () { $bearer = dnsApiToken($this->user, $this->team); $list = $this->withToken($bearer)->getJson('/api/v1/security/integration-tokens')->assertOk(); $one = $this->withToken($bearer)->getJson("/api/v1/security/integration-tokens/{$this->token->uuid}")->assertOk(); expect($list->getContent())->not->toContain('cf-secret-value') ->and($one->getContent())->not->toContain('cf-secret-value') ->and($one->json())->toMatchArray([ 'uuid' => $this->token->uuid, 'provider' => 'cloudflare', 'capabilities' => ['dns'], 'automatic_dns' => true, 'proxied_dns' => false, 'dns_zones_count' => 1, ]); }); test('a member cannot list or read integration tokens', function () { $bearer = dnsApiToken($this->member, $this->team, ['read']); $this->withToken($bearer)->getJson('/api/v1/security/integration-tokens')->assertForbidden(); $this->withToken($bearer)->getJson("/api/v1/security/integration-tokens/{$this->token->uuid}")->assertForbidden(); $this->withToken($bearer)->getJson('/api/v1/dns/zones')->assertForbidden(); }); test('tokens of another team are not found', function (string $method, string $suffix) { $foreign = IntegrationToken::factory()->for(Team::factory()->create())->create(['provider' => 'cloudflare']); $this->withToken(dnsApiToken($this->user, $this->team)) ->json($method, "/api/v1/security/integration-tokens/{$foreign->uuid}{$suffix}") ->assertNotFound(); expect($foreign->fresh())->not->toBeNull(); })->with([ 'get' => ['GET', ''], 'delete' => ['DELETE', ''], 'update' => ['PATCH', ''], 'sync zones' => ['POST', '/sync-zones'], ]); test('a token that manages dns records cannot be deleted', function () { ManagedDnsRecord::factory()->create(['team_id' => $this->team->id, 'integration_token_id' => $this->token->id, 'dns_provider_zone_id' => $this->zone->id]); $this->withToken(dnsApiToken($this->user, $this->team)) ->deleteJson("/api/v1/security/integration-tokens/{$this->token->uuid}") ->assertConflict() ->assertJson(['message' => 'This token manages DNS records. Remove those domains or records first.']); expect($this->token->fresh())->not->toBeNull(); }); test('an unused token is deleted', function () { $this->withToken(dnsApiToken($this->user, $this->team)) ->deleteJson("/api/v1/security/integration-tokens/{$this->token->uuid}") ->assertOk(); expect($this->token->fresh())->toBeNull(); }); test('a cloudflare token is renamed and its automatic dns turned off after cloudflare accepts it', function () { Http::fake([ 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response(['result' => ['status' => 'active']]), 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records*' => Http::response(['success' => true, 'result' => []]), 'https://api.cloudflare.com/client/v4/zones*' => Http::response(['success' => true, 'result' => [['id' => 'zone-1', 'name' => 'example.com']]]), ]); $response = $this->withToken(dnsApiToken($this->user, $this->team)) ->patchJson("/api/v1/security/integration-tokens/{$this->token->uuid}", ['name' => 'Renamed', 'automatic_dns' => false]) ->assertOk() ->assertJson(['name' => 'Renamed', 'automatic_dns' => false, 'capabilities' => ['dns']]); expect($response->getContent())->not->toContain('cf-secret-value') ->and($this->token->fresh()->automaticDnsEnabled())->toBeFalse() ->and($this->token->fresh()->token)->toBe('cf-secret-value'); Http::assertSent(fn (HttpRequest $request): bool => $request->url() === 'https://api.cloudflare.com/client/v4/user/tokens/verify' && $request->header('Authorization') === ['Bearer cf-secret-value']); }); test('a vault base url cannot change without the token again', function () { Http::fake(); $vault = IntegrationToken::factory()->for($this->team)->create([ 'provider' => 'vault', 'token' => 'vault-secret', 'capabilities' => ['secrets'], 'metadata' => ['base_url' => 'https://vault.example.com'], ]); $this->withToken(dnsApiToken($this->user, $this->team)) ->patchJson("/api/v1/security/integration-tokens/{$vault->uuid}", ['metadata' => ['base_url' => 'https://attacker.example.com']]) ->assertUnprocessable() ->assertJsonPath('errors.token.0', 'Enter the token again when you change the base URL or the namespace.'); expect($vault->fresh()->metadata['base_url'])->toBe('https://vault.example.com'); Http::assertNothingSent(); }); test('a member cannot update a token', function () { $this->withToken(dnsApiToken($this->member, $this->team, ['read'])) ->patchJson("/api/v1/security/integration-tokens/{$this->token->uuid}", ['name' => 'Renamed']) ->assertForbidden(); expect($this->token->fresh()->name)->not->toBe('Renamed'); }); test('zones of a cloudflare token are synchronized again', function () { Http::fake(['https://api.cloudflare.com/client/v4/zones*' => Http::response([ 'success' => true, 'result' => [['id' => 'zone-1', 'name' => 'example.com'], ['id' => 'zone-2', 'name' => 'example.net']], 'result_info' => ['total_pages' => 1], ])]); $this->withToken(dnsApiToken($this->user, $this->team)) ->postJson("/api/v1/security/integration-tokens/{$this->token->uuid}/sync-zones") ->assertOk() ->assertJson(['zones_count' => 2]); expect($this->token->dnsZones()->orderBy('name')->pluck('name')->all())->toBe(['example.com', 'example.net']); }); test('a cloudflare connection failure returns 502', function (string $method, string $path, array $body) { Http::fake(fn () => throw new ConnectionException('Connection refused')); $this->withToken(dnsApiToken($this->user, $this->team)) ->json($method, str_replace('{uuid}', $this->token->uuid, $path), $body) ->assertStatus(502) ->assertJson(['message' => 'Could not connect to the Cloudflare API. Check the network connection of Coolify and try again.']); expect(IntegrationToken::query()->where('name', 'Offline')->exists())->toBeFalse(); })->with([ 'create' => ['POST', '/api/v1/security/integration-tokens', ['provider' => 'cloudflare', 'name' => 'Offline', 'token' => 'x']], 'update' => ['PATCH', '/api/v1/security/integration-tokens/{uuid}', ['name' => 'Offline', 'token' => 'new-secret']], 'sync zones' => ['POST', '/api/v1/security/integration-tokens/{uuid}/sync-zones', []], ]); }); describe('resource dns', function () { test('the zones and the dns status of an application are listed', function () { $bearer = dnsApiToken($this->user, $this->team); $this->withToken($bearer)->getJson('/api/v1/dns/zones')->assertOk() ->assertExactJson([[ 'zone' => 'example.com', 'account_name' => $this->zone->account_name, 'integration_token_uuid' => $this->token->uuid, 'integration_token_name' => $this->token->name, 'managed_records_count' => 0, ]]); $this->withToken($bearer)->getJson("/api/v1/applications/{$this->application->uuid}/dns")->assertOk() ->assertJson([ 'default_address' => '203.0.113.10', 'public_addresses' => ['203.0.113.10'], 'hostnames' => [[ 'hostname' => 'app.example.com', 'providers' => [['integration_token_uuid' => $this->token->uuid, 'zone' => 'example.com', 'automatic_dns' => true]], 'records' => [], ]], ]); }); test('a record is created for a domain of the application', function () { dnsApiFakeCloudflare(); $this->withToken(dnsApiToken($this->user, $this->team)) ->postJson("/api/v1/applications/{$this->application->uuid}/dns", ['hostname' => 'APP.example.com']) ->assertCreated() ->assertJson(['record' => ['hostname' => 'app.example.com', 'type' => 'A', 'address' => '203.0.113.10', 'owned' => true]]); $record = ManagedDnsRecord::query()->where('name', 'app.example.com')->firstOrFail(); expect($record->references()->pluck('resource_id')->all())->toBe([$this->application->id]); Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'POST' && $request['content'] === '203.0.113.10'); $this->assertDatabaseHas('audit_events', ['team_id' => $this->team->id, 'event' => 'api.dns_record.created']); }); test('a record is created for a domain of a service application', function () { dnsApiFakeCloudflare(); $serviceApplication = dnsApiService($this, 'https://web.example.com'); $this->withToken(dnsApiToken($this->user, $this->team)) ->postJson("/api/v1/services/{$serviceApplication->service->uuid}/dns", ['hostname' => 'web.example.com']) ->assertCreated(); $reference = ManagedDnsRecord::query()->where('name', 'web.example.com')->firstOrFail()->references()->firstOrFail(); expect($reference->resource_type)->toBe($serviceApplication->getMorphClass()) ->and($reference->resource_id)->toBe($serviceApplication->id); }); test('a conflicting record returns 409 and can then be replaced', function () { dnsApiFakeCloudflare([['id' => 'record-old', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.7']]); $bearer = dnsApiToken($this->user, $this->team); $conflict = $this->withToken($bearer) ->postJson("/api/v1/applications/{$this->application->uuid}/dns", ['hostname' => 'app.example.com']) ->assertConflict() ->assertJson(['conflict' => ['record_id' => 'record-old', 'current' => '198.51.100.7', 'proposed' => '203.0.113.10']]) ->json('conflict'); $this->withToken($bearer)->postJson("/api/v1/applications/{$this->application->uuid}/dns/replace", [ 'hostname' => 'app.example.com', 'record_id' => $conflict['record_id'], 'current' => $conflict['current'], ])->assertOk()->assertJson(['record' => ['address' => '203.0.113.10', 'owned' => false]]); Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'PATCH' && str_ends_with($request->url(), '/dns_records/record-old') && $request['content'] === '203.0.113.10'); }); test('a busy hostname returns 423 without a cloudflare call', function () { Http::fake(); app()->instance(ManagedDnsHostnameLock::class, new ManagedDnsHostnameLock(waitSeconds: 0)); $lock = Cache::lock(ManagedDnsHostnameLock::key($this->team->id, 'app.example.com'), 60); $lock->get(); $this->withToken(dnsApiToken($this->user, $this->team)) ->postJson("/api/v1/applications/{$this->application->uuid}/dns", ['hostname' => 'app.example.com']) ->assertStatus(423) ->assertJson(['message' => 'Another DNS change for app.example.com is in progress. Try again in a few seconds.']); $lock->release(); Http::assertNothingSent(); }); test('no record is created when the server has no public address', function () { Http::fake(); $this->server->update(['ip' => '10.0.0.5']); $this->withToken(dnsApiToken($this->user, $this->team)) ->postJson("/api/v1/applications/{$this->application->uuid}/dns", ['hostname' => 'app.example.com']) ->assertUnprocessable() ->assertJsonPath('errors.address.0', DnsRecordHints::NO_PUBLIC_ADDRESS_MESSAGE); Http::assertNothingSent(); }); test('a record is only created for a public address of the server and a domain of the resource', function (array $payload, string $field, string $message) { Http::fake(); $this->withToken(dnsApiToken($this->user, $this->team)) ->postJson("/api/v1/applications/{$this->application->uuid}/dns", $payload) ->assertUnprocessable() ->assertJsonPath("errors.{$field}.0", $message); Http::assertNothingSent(); })->with([ 'other hostname' => [['hostname' => 'other.example.com'], 'hostname', 'other.example.com is not a domain of this resource.'], 'other address' => [['hostname' => 'app.example.com', 'address' => '198.51.100.7'], 'address', 'The address must be a public address of the server: 203.0.113.10.'], 'other token' => [['hostname' => 'app.example.com', 'integration_token_uuid' => 'unknown'], 'integration_token_uuid', 'No connected DNS provider of the team manages app.example.com.'], ]); test('only zones of the resource team are used', function () { Http::fake(); $this->zone->delete(); $foreignToken = IntegrationToken::factory()->for(Team::factory()->create())->create(['provider' => 'cloudflare']); DnsProviderZone::factory()->for($foreignToken)->create(['name' => 'example.com']); $this->withToken(dnsApiToken($this->user, $this->team)) ->postJson("/api/v1/applications/{$this->application->uuid}/dns", ['hostname' => 'app.example.com', 'integration_token_uuid' => $foreignToken->uuid]) ->assertUnprocessable(); Http::assertNothingSent(); }); test('a member with read access can see the dns status but not change it', function () { Http::fake(); $bearer = dnsApiToken($this->member, $this->team, ['read']); $this->withToken($bearer)->getJson("/api/v1/applications/{$this->application->uuid}/dns")->assertOk(); $this->withToken($bearer) ->postJson("/api/v1/applications/{$this->application->uuid}/dns", ['hostname' => 'app.example.com']) ->assertForbidden(); Http::assertNothingSent(); }); test('resources of another team are not found', function (string $method, string $path) { Http::fake(); $otherTeam = Team::factory()->create(); $otherTeam->members()->attach($this->user->id, ['role' => 'owner']); $this->withToken(dnsApiToken($this->user, $otherTeam)) ->json($method, str_replace('{uuid}', $this->application->uuid, $path), ['hostname' => 'app.example.com']) ->assertNotFound(); Http::assertNothingSent(); })->with([ 'status' => ['GET', '/api/v1/applications/{uuid}/dns'], 'create' => ['POST', '/api/v1/applications/{uuid}/dns'], 'release' => ['DELETE', '/api/v1/applications/{uuid}/dns/app.example.com'], ]); test('the record of a removed domain is released and deleted', function () { $record = ManagedDnsRecord::factory()->create([ 'team_id' => $this->team->id, 'integration_token_id' => $this->token->id, 'dns_provider_zone_id' => $this->zone->id, 'provider_record_id' => 'record-new', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'owned' => true, ]); $record->addReference($this->application); $this->application->update(['fqdn' => 'https://new.example.com']); Http::fake(['https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/record-new' => Http::response(['success' => true, 'result' => [ 'id' => 'record-new', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => $record->ownershipComment(), ]])]); $this->withToken(dnsApiToken($this->user, $this->team)) ->deleteJson("/api/v1/applications/{$this->application->uuid}/dns/app.example.com") ->assertOk() ->assertJson(['result' => 'deleted']); expect($record->fresh())->toBeNull(); Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'DELETE'); }); test('the record of a domain that the application still uses is not released', function () { dnsApiFakeCloudflare(); $record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $this->application); $this->withToken(dnsApiToken($this->user, $this->team)) ->deleteJson("/api/v1/applications/{$this->application->uuid}/dns/app.example.com") ->assertUnprocessable(); expect($record->fresh())->not->toBeNull(); Http::assertNotSent(fn (HttpRequest $request): bool => $request->method() === 'DELETE'); }); }); describe('automatic dns on api domain changes', function () { test('an added application domain queues a record when the zone has automatic dns', function () { Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); $this->withToken(dnsApiToken($this->user, $this->team)) ->patchJson("/api/v1/applications/{$this->application->uuid}", ['domains' => 'https://app.example.com,https://new.example.com']) ->assertOk(); Queue::assertPushed(ConfigureDnsRecordJob::class, 1); Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'new.example.com' && $job->content === '203.0.113.10' && $job->zoneId === $this->zone->id && $job->resourceId === $this->application->id); }); test('an added service domain queues a record when the zone has automatic dns', function () { Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); $serviceApplication = dnsApiService($this, 'https://web.example.com'); $this->withToken(dnsApiToken($this->user, $this->team)) ->patchJson("/api/v1/services/{$serviceApplication->service->uuid}", ['urls' => [['name' => 'web', 'url' => 'https://web.example.com,https://shop.example.com']]]) ->assertOk(); Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'shop.example.com' && $job->resourceId === $serviceApplication->id); Queue::assertNotPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'web.example.com'); }); test('an application created through the api queues a record for its domain', function () { Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); $uuid = $this->withToken(dnsApiToken($this->user, $this->team)) ->postJson('/api/v1/applications/dockerimage', [ 'project_uuid' => $this->environment->project->uuid, 'environment_uuid' => $this->environment->uuid, 'server_uuid' => $this->server->uuid, 'docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => 'latest', 'ports_exposes' => '80', 'domains' => 'https://api-created.example.com', 'instant_deploy' => false, ]) ->assertCreated() ->json('uuid'); Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'api-created.example.com' && $job->content === '203.0.113.10' && $job->resourceId === Application::query()->where('uuid', $uuid)->value('id')); }); test('a service created through the api queues a record for its domain', function () { Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); $this->withToken(dnsApiToken($this->user, $this->team)) ->postJson('/api/v1/services', [ 'project_uuid' => $this->environment->project->uuid, 'environment_uuid' => $this->environment->uuid, 'server_uuid' => $this->server->uuid, 'docker_compose_raw' => base64_encode("services:\n web:\n image: nginx:alpine\n"), 'urls' => [['name' => 'web', 'url' => 'https://web-created.example.com']], 'instant_deploy' => false, ]) ->assertCreated(); Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'web-created.example.com' && $job->resourceType === (new ServiceApplication)->getMorphClass()); }); test('a clone queues records only for the domains that the source does not use', function () { Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); $this->application->settings()->update(['is_container_label_readonly_enabled' => false]); $this->application->update(['fqdn' => 'https://app.example.com,https://cloned.example.com']); $source = $this->application->fresh(); $clone = clone_application($source, $this->destination, ['fqdn' => 'https://app.example.com,https://clone-only.example.com']); Queue::assertPushed(ConfigureDnsRecordJob::class, 1); Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'clone-only.example.com' && $job->resourceId === $clone->id); }); test('a cloned service queues records for its generated domains', function () { Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); $this->server->settings()->update(['wildcard_domain' => 'https://example.com']); $source = dnsApiService($this, 'https://web.example.com')->service; $source->update(['docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n environment:\n - SERVICE_FQDN_WEB\n"]); $clone = clone_service($source->fresh(), $this->destination); $cloneHostnames = array_keys(app(ResourceDnsRecords::class)->hostnameUsers($clone)); expect($cloneHostnames)->not->toBeEmpty()->not->toContain('web.example.com'); foreach ($cloneHostnames as $hostname) { Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === $hostname); } }); test('no record is queued when automatic dns is off', function () { Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]); $this->token->update(['metadata' => ['automatic_dns' => false]]); $this->withToken(dnsApiToken($this->user, $this->team)) ->patchJson("/api/v1/applications/{$this->application->uuid}", ['domains' => 'https://app.example.com,https://new.example.com']) ->assertOk(); Queue::assertNotPushed(ConfigureDnsRecordJob::class); }); });