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
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,20 @@ jobs:
- name: Test
run: dotnet test --no-build -c Release --logger "console;verbosity=normal" --collect "XPlat Code Coverage" --results-directory ./coverage

- name: Upload coverage
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: ./coverage/**/coverage.cobertura.xml
retention-days: 14

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: ./coverage/**/coverage.cobertura.xml
# fail_ci_if_error: false — Codecov outages should not block CI
fail_ci_if_error: false

- name: Validate zone JSON Schema
run: npx --yes ajv-cli@5 compile -s schemas/zone.schema.json
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# dns-sync

[![CI](https://github.com/cl8dep/dns-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/cl8dep/dns-sync/actions/workflows/ci.yml)
[![Coverage](https://codecov.io/gh/cl8dep/dns-sync/graph/badge.svg)](https://codecov.io/gh/cl8dep/dns-sync)
[![Release](https://img.shields.io/github/v/release/cl8dep/dns-sync)](https://github.com/cl8dep/dns-sync/releases/latest)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

Expand Down
19 changes: 19 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
coverage:
status:
# Block PR merge if project coverage drops by more than 1%
project:
default:
target: auto # compare against base commit
threshold: 1% # allow up to 1% drop before failing
# Annotate PR with coverage change on the modified lines only
patch:
default:
target: 80% # require at least 80% coverage on new/changed lines
threshold: 5%

comment:
layout: "reach,diff,flags,files"
behavior: default # post once, update on each push
require_changes: false # comment even when coverage is unchanged
require_base: false # comment even on first push (no base to compare)
hide_project_coverage: false
13 changes: 13 additions & 0 deletions src/DnsSync/Config/DnsSyncConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ public class ZoneConfig
public List<string> Targets { get; set; } = new();
}

/// <summary>
/// Defines a group of zones discovered automatically from a source provider.
///
/// At runtime, dns-sync calls GetZonesAsync() on the source provider and adds every
/// discovered zone (after filtering) as if it were declared under zones: with the same
/// source and targets. Explicit zones: entries always win — a zone already declared
/// explicitly will not be overridden by a group.
///
/// Failure behaviour: if zone discovery fails (network error, auth error, etc.) the
/// group is skipped with a warning and dns-sync continues with whatever zones were
/// already resolved. This is intentional — a transient provider failure should not
/// prevent syncing the zones that are already known.
/// </summary>
public class ZoneGroupConfig
{
[YamlMember(Alias = "source")]
Expand Down
5 changes: 4 additions & 1 deletion src/DnsSync/Providers/Cloudflare/CloudflareProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@ public class CloudflareProvider : IProvider
};

public CloudflareProvider(string apiToken, ILogger<CloudflareProvider> logger, string? accountId = null)
: this(apiToken, logger, new HttpClient { Timeout = TimeSpan.FromSeconds(30) }, accountId) { }

internal CloudflareProvider(string apiToken, ILogger<CloudflareProvider> logger, HttpClient http, string? accountId = null)
{
_logger = logger;
_accountId = accountId;
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
_http = http;
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", apiToken);
_http.DefaultRequestHeaders.Accept.Add(
Expand Down
12 changes: 12 additions & 0 deletions src/DnsSync/Providers/Gcp/GcpCloudDnsProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ public GcpCloudDnsProvider(
_project = project;
}

/// <summary>Internal constructor for unit tests — bypasses credential loading.</summary>
internal GcpCloudDnsProvider(string project, ILogger<GcpCloudDnsProvider> logger, HttpClient http, bool? privateZones = null)
{
_project = project;
_logger = logger;
_http = http;
_privateZones = privateZones;
// Pre-set a non-expiring token so GetAccessTokenAsync returns immediately without I/O.
_accessToken = "fake-token";
_tokenExpiry = DateTimeOffset.MaxValue;
}

// ─── IProvider ────────────────────────────────────────────────────────────

public async Task PreflightAsync(CancellationToken ct = default)
Expand Down
5 changes: 4 additions & 1 deletion src/DnsSync/Providers/GoDaddy/GoDaddyProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ public class GoDaddyProvider : IProvider
};

public GoDaddyProvider(string apiKey, string secretKey, ILogger<GoDaddyProvider> logger)
: this(apiKey, secretKey, logger, new HttpClient { Timeout = TimeSpan.FromSeconds(30) }) { }

internal GoDaddyProvider(string apiKey, string secretKey, ILogger<GoDaddyProvider> logger, HttpClient http)
{
_logger = logger;
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
_http = http;
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("sso-key", $"{apiKey}:{secretKey}");
_http.DefaultRequestHeaders.Accept.Add(
Expand Down
7 changes: 5 additions & 2 deletions src/DnsSync/Providers/Porkbun/PorkbunProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,14 @@ public class PorkbunProvider : IProvider
};

public PorkbunProvider(string apiKey, string secretKey, ILogger<PorkbunProvider> logger)
: this(apiKey, secretKey, logger, new HttpClient { Timeout = TimeSpan.FromSeconds(30) }) { }

internal PorkbunProvider(string apiKey, string secretKey, ILogger<PorkbunProvider> logger, HttpClient http)
{
_apiKey = apiKey;
_secretKey = secretKey;
_logger = logger;
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
_http = http;
}

// ─── IProvider ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -202,7 +205,7 @@ private async Task DeleteRecordAsync(string domain, string id, CancellationToken
{
if (!r.TryGetProperty("id", out var idEl)) continue;
var id = idEl.GetString()!;
var name = NormalizeFqdn(r.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "");
var name = BuildFqdn(r.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "", domain + ".");
var type = (r.TryGetProperty("type", out var t) ? t.GetString() : null)?.ToUpperInvariant() ?? "";

var key = (name, type);
Expand Down
14 changes: 13 additions & 1 deletion src/DnsSync/Providers/Route53/Route53Provider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,26 @@ public Route53Provider(
ILogger<Route53Provider> logger,
string? sessionToken = null,
string? hostedZoneId = null)
: this(accessKeyId, secretAccessKey, region, logger,
new HttpClient { Timeout = TimeSpan.FromSeconds(30) }, sessionToken, hostedZoneId)
{ }

internal Route53Provider(
string accessKeyId,
string secretAccessKey,
string region,
ILogger<Route53Provider> logger,
HttpClient http,
string? sessionToken = null,
string? hostedZoneId = null)
{
_accessKeyId = accessKeyId;
_secretAccessKey = secretAccessKey;
_sessionToken = sessionToken;
_region = region;
_configuredZoneId = hostedZoneId;
_logger = logger;
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
_http = http;
}

public async Task PreflightAsync(CancellationToken ct = default)
Expand Down
61 changes: 61 additions & 0 deletions tests/DnsSync.Tests/Helpers/FakeHttpHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Net;
using System.Text;

namespace DnsSync.Tests.Helpers;

/// <summary>
/// A fake HttpMessageHandler that returns pre-queued responses and records all requests made.
/// Use <see cref="Enqueue"/> to set up responses in order before invoking provider methods.
/// </summary>
internal sealed class FakeHttpHandler : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _queue = new();

/// <summary>All requests received in order.</summary>
public List<HttpRequestMessage> Requests { get; } = [];

/// <summary>Shorthand: the most recent request received.</summary>
public HttpRequestMessage LastRequest => Requests[^1];

/// <summary>Enqueue a response with the given status code and JSON body.</summary>
public void Enqueue(HttpStatusCode code, string body, string contentType = "application/json")
{
_queue.Enqueue(new HttpResponseMessage(code)
{
Content = new StringContent(body, Encoding.UTF8, contentType)
});
}

/// <summary>Enqueue a 200 OK response with the given body.</summary>
public void Enqueue(string body, string contentType = "application/json") =>
Enqueue(HttpStatusCode.OK, body, contentType);

/// <summary>Number of responses still queued (not yet consumed).</summary>
public int QueueDepth => _queue.Count;

/// <summary>
/// Asserts that all queued responses were consumed. Call at the end of a test to
/// catch cases where fewer HTTP requests were made than expected.
/// </summary>
public void VerifyAllConsumed()
{
if (_queue.Count > 0)
throw new InvalidOperationException(
$"FakeHttpHandler has {_queue.Count} unconsumed response(s). " +
"Fewer HTTP requests were made than expected.");
}

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(request);

if (_queue.Count == 0)
throw new InvalidOperationException(
$"FakeHttpHandler has no more queued responses but received a request to: {request.RequestUri}");

return Task.FromResult(_queue.Dequeue());
}

/// <summary>Creates an HttpClient backed by this handler.</summary>
public HttpClient CreateClient() => new(this);
}
Loading
Loading