Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions app/Actions/Domain/AddDomain.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Models\Domain;
use App\Models\Project;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
Expand Down Expand Up @@ -34,18 +35,28 @@ public function add(User $user, Project $project, array $input): Domain
]);
}

$domain = new Domain;
$domain->dns_provider_id = $dnsProvider->id;
$domain->user_id = $user->id;
$domain->project_id = $project->id;
$domain->domain = $domainData['name'];
$domain->provider_domain_id = $domainData['id'];
$domain->metadata = $domainData;
$domain->save();
try {
return DB::transaction(function () use ($user, $project, $dnsProvider, $domainData) {
$domain = new Domain;
$domain->dns_provider_id = $dnsProvider->id;
$domain->user_id = $user->id;
$domain->project_id = $project->id;
$domain->domain = $domainData['name'];
$domain->provider_domain_id = $domainData['id'];
$domain->metadata = $domainData;
$domain->save();

$domain->syncDnsRecords();
$domain->syncDnsRecords();

return $domain;
return $domain;
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
throw ValidationException::withMessages([
'domain' => [$e->getMessage()],
]);
}
}

private function validate(array $input): void
Expand Down
73 changes: 33 additions & 40 deletions app/DNSProviders/Cloudflare.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,49 +129,42 @@ public function getDomain(string $domainId): array

public function getRecords(string $domainId): array
{
try {
$response = $this->getClient()->get("zones/{$domainId}/dns_records", [
'per_page' => 100,
]);

if (! $response->successful()) {
Log::error('Failed to fetch Cloudflare DNS records', ['domainId' => $domainId, 'response' => $response->json()]);
$response = $this->getClient()->get("zones/{$domainId}/dns_records", [
'per_page' => 100,
]);

return [];
}

return collect($response->json('result'))->map(function (array $record) {
return [
'id' => $record['id'],
'type' => $record['type'],
'name' => $record['name'],
'content' => $record['content'],
'ttl' => $record['ttl'],
'proxied' => $record['proxied'],
'created_on' => $record['created_on'],
'modified_on' => $record['modified_on'],
];
})->toArray();
} catch (Throwable $e) {
Log::error('Cloudflare getRecords exception', ['error' => $e->getMessage()]);

return [];
if (! $response->successful()) {
Log::error('Failed to fetch Cloudflare DNS records', ['domainId' => $domainId, 'response' => $response->json()]);
throw new \RuntimeException('Failed to fetch DNS records: '.($response->json('errors')[0]['message'] ?? 'Unknown error'));
}

return collect($response->json('result'))->map(function (array $record) {
return [
'id' => $record['id'],
'type' => $record['type'],
'name' => $record['name'],
'content' => $record['content'],
'ttl' => $record['ttl'],
'proxied' => $record['proxied'],
'created_on' => $record['created_on'],
'modified_on' => $record['modified_on'],
];
})->toArray();
}

public function createRecord(string $domainId, array $input): array
public function createRecord(string $domainId, array $recordData): array
{
try {
$response = $this->getClient()->post("zones/{$domainId}/dns_records", [
'type' => $input['type'],
'name' => $input['name'],
'content' => $input['content'],
'ttl' => $input['ttl'] ?? 1,
'proxied' => $input['proxied'] ?? false,
'type' => $recordData['type'],
'name' => $recordData['name'],
'content' => $recordData['content'],
'ttl' => $recordData['ttl'] ?? 1,
'proxied' => $recordData['proxied'] ?? false,
]);

if (! $response->successful()) {
Log::error('Failed to create Cloudflare DNS record', ['domainId' => $domainId, 'input' => $input, 'response' => $response->json()]);
Log::error('Failed to create Cloudflare DNS record', ['domainId' => $domainId, 'input' => $recordData, 'response' => $response->json()]);
throw ValidationException::withMessages(['record' => 'Failed to create DNS record: '.($response->json('errors')[0]['message'] ?? 'Unknown error')]);
}

Expand All @@ -182,19 +175,19 @@ public function createRecord(string $domainId, array $input): array
}
}

public function updateRecord(string $domainId, string $recordId, array $input): array
public function updateRecord(string $domainId, string $recordId, array $recordData): array
{
try {
$response = $this->getClient()->put("zones/{$domainId}/dns_records/{$recordId}", [
'type' => $input['type'],
'name' => $input['name'],
'content' => $input['content'],
'ttl' => $input['ttl'] ?? 1,
'proxied' => $input['proxied'] ?? false,
'type' => $recordData['type'],
'name' => $recordData['name'],
'content' => $recordData['content'],
'ttl' => $recordData['ttl'] ?? 1,
'proxied' => $recordData['proxied'] ?? false,
]);

if (! $response->successful()) {
Log::error('Failed to update Cloudflare DNS record', ['domainId' => $domainId, 'recordId' => $recordId, 'input' => $input, 'response' => $response->json()]);
Log::error('Failed to update Cloudflare DNS record', ['domainId' => $domainId, 'recordId' => $recordId, 'input' => $recordData, 'response' => $response->json()]);
throw ValidationException::withMessages(['record' => 'Failed to update DNS record: '.($response->json('errors')[0]['message'] ?? 'Unknown error')]);
}

Expand Down
6 changes: 5 additions & 1 deletion app/Http/Controllers/API/DNSRecordController.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ public function sync(Project $project, Domain $domain): JsonResponse
$this->authorize('update', $domain);
$this->validateRoute($project, $domain);

$domain->syncDnsRecords();
try {
$domain->syncDnsRecords();
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to sync DNS records: '.$e->getMessage()], 422);
}

return response()->json(['message' => 'DNS records synced successfully']);
}
Expand Down
6 changes: 5 additions & 1 deletion app/Http/Controllers/DNSRecordController.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,11 @@ public function sync(Domain $domain): RedirectResponse
{
$this->authorize('update', $domain);

$domain->syncDnsRecords();
try {
$domain->syncDnsRecords();
} catch (\Throwable $e) {
return back()->with('error', 'Failed to sync DNS records: '.$e->getMessage());
}

return back()->with('success', 'DNS records synced successfully.');
}
Expand Down
17 changes: 7 additions & 10 deletions app/Models/Domain.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Log;
use Throwable;
use Illuminate\Support\Facades\DB;

/**
* @property int $dns_provider_id
Expand Down Expand Up @@ -74,11 +73,14 @@ public function records(): HasMany
return $this->hasMany(DNSRecord::class);
}

/**
* @throws \Throwable
*/
public function syncDnsRecords(): void
{
try {
$records = $this->dnsProvider->provider()->getRecords($this->provider_domain_id);
$records = $this->dnsProvider->provider()->getRecords($this->provider_domain_id);

DB::transaction(function () use ($records) {
DNSRecord::where('domain_id', $this->id)->delete();

foreach ($records as $recordData) {
Expand All @@ -93,11 +95,6 @@ public function syncDnsRecords(): void
'metadata' => $recordData,
]);
}
} catch (Throwable $e) {
Log::error('Failed to sync DNS records for domain: '.$this->domain, [
'error' => $e->getMessage(),
'domain_id' => $this->id,
]);
}
});
}
}
1 change: 1 addition & 0 deletions resources/js/pages/domains/components/add-domain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export default function AddDomain({ children }: { children: ReactNode }) {
</Select>
<InputError message={form.errors.provider_domain_id} />
</FormField>
<InputError message={(form.errors as Record<string, string>).domain} />
</FormFields>
</Form>
<DialogFooter>
Expand Down
27 changes: 27 additions & 0 deletions tests/Feature/API/DNSRecordTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,33 @@ public function test_authenticated_user_can_sync_dns_records(): void
]);
}

public function test_sync_dns_records_returns_error_when_provider_fails(): void
{
Sanctum::actingAs($this->user, ['write']);

$dnsProvider = DNSProvider::factory()->create([
'user_id' => $this->user->id,
'project_id' => $this->user->current_project_id,
]);

$domain = Domain::factory()->create([
'user_id' => $this->user->id,
'dns_provider_id' => $dnsProvider->id,
'project_id' => $this->user->current_project_id,
'provider_domain_id' => 'test-domain-id',
]);

// Mock the DNS provider API call to throw an exception
Http::fake(function () {
throw new \RuntimeException('Domain is not opted in to API access.');
});

$response = $this->postJson("/api/projects/{$this->user->current_project_id}/domains/{$domain->id}/records/sync");

$response->assertStatus(422);
$this->assertStringContainsString('Failed to sync DNS records', $response->json('message'));
}

public function test_user_cannot_sync_dns_records_for_other_users_domain(): void
{
Sanctum::actingAs($this->user, ['write']);
Expand Down
52 changes: 51 additions & 1 deletion tests/Feature/API/DomainsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,12 @@ public function test_authenticated_user_can_create_domain(): void
'project_id' => $this->user->current_project_id,
]);

// Mock the DNS provider API call
// Mock the DNS provider API calls (getDomain + getRecords)
Http::fake([
'api.cloudflare.com/client/v4/zones/test-domain-id/dns_records*' => Http::response([
'result' => [],
'success' => true,
], 200),
'api.cloudflare.com/*' => Http::response([
'result' => [
'id' => 'test-domain-id',
Expand Down Expand Up @@ -214,6 +218,52 @@ public function test_authenticated_user_can_create_domain(): void
]);
}

public function test_create_domain_fails_when_record_sync_fails(): void
{
Sanctum::actingAs($this->user, ['write']);

$dnsProvider = DNSProvider::factory()->create([
'user_id' => $this->user->id,
'project_id' => $this->user->current_project_id,
]);

$callCount = 0;

// First call (getDomain) succeeds, subsequent calls throw
Http::fake(function () use (&$callCount) {
$callCount++;
if ($callCount === 1) {
return Http::response([
'result' => [
'id' => 'test-domain-id',
'name' => 'example.com',
'status' => 'active',
'created_on' => '2023-01-01T00:00:00Z',
'modified_on' => '2023-01-01T00:00:00Z',
],
'success' => true,
], 200);
}

throw new \RuntimeException('Domain is not opted in to API access.');
});

$domainData = [
'dns_provider_id' => $dnsProvider->id,
'provider_domain_id' => 'test-domain-id',
];

$response = $this->postJson("/api/projects/{$this->user->current_project_id}/domains", $domainData);

$response->assertUnprocessable()
->assertJsonValidationErrors('domain');

// Domain should not have been persisted due to transaction rollback
$this->assertDatabaseMissing('domains', [
'provider_domain_id' => 'test-domain-id',
]);
}

public function test_user_cannot_create_domain_with_dns_provider_from_other_project(): void
{
Sanctum::actingAs($this->user, ['write']);
Expand Down
Loading
Loading