Ctrl K
ComunidadGuías…

tests/Feature/DnsApiTest.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php

use App\Jobs\CheckDomainDnsJob;
use App\Jobs\ConfigureDnsRecordJob;
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 App\Services\Dns\ManagedDnsHostnameLock;
use App\Services\Dns\ResourceDnsRecords;
use App\Support\DnsRecordHints;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request as HttpRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;

uses(RefreshDatabase::class);

beforeEach(function () {
    Server::flushIdentityMap();
    Queue::fake([CheckDomainDnsJob::class]);
    config()->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<int, array<string, mixed>>  $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);
    });
});