mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
576 lines
30 KiB
PHP
576 lines
30 KiB
PHP
<?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);
|
|
});
|
|
});
|