From df0f60ef29f579555d558d65e2d3d00c9a4eb3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Thu, 6 Nov 2025 20:49:50 +0100 Subject: [PATCH] Added DNS firewall configuration --- .../Cloudflare.Dns/DnsFirewallExtensions.cs | 154 +++++++++++ .../Filters/ListDNSFirewallClustersFilter.cs | 32 +++ .../InternalDNSFirewallClusterRequest.cs | 35 +++ .../Models/DnsFirewallCluster.cs | 140 ++++++++++ src/Extensions/Cloudflare.Dns/README.md | 10 + .../CreateDNSFirewallClusterRequest.cs | 24 ++ .../Requests/DNSFirewallClusterRequestBase.cs | 93 +++++++ .../UpdateDNSFirewallClusterRequest.cs | 29 ++ .../CreateDNSFirewallClusterTest.cs | 250 ++++++++++++++++++ .../DNSFirewallClusterDetailsTest.cs | 97 +++++++ .../DeleteDNSFirewallClusterTest.cs | 72 +++++ .../ListDNSFirewallClustersTest.cs | 211 +++++++++++++++ .../UpdateDNSFirewallClusterTest.cs | 250 ++++++++++++++++++ 13 files changed, 1397 insertions(+) create mode 100644 src/Extensions/Cloudflare.Dns/DnsFirewallExtensions.cs create mode 100644 src/Extensions/Cloudflare.Dns/Filters/ListDNSFirewallClustersFilter.cs create mode 100644 src/Extensions/Cloudflare.Dns/Internals/InternalDNSFirewallClusterRequest.cs create mode 100644 src/Extensions/Cloudflare.Dns/Models/DnsFirewallCluster.cs create mode 100644 src/Extensions/Cloudflare.Dns/Requests/CreateDNSFirewallClusterRequest.cs create mode 100644 src/Extensions/Cloudflare.Dns/Requests/DNSFirewallClusterRequestBase.cs create mode 100644 src/Extensions/Cloudflare.Dns/Requests/UpdateDNSFirewallClusterRequest.cs create mode 100644 test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/CreateDNSFirewallClusterTest.cs create mode 100644 test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/DNSFirewallClusterDetailsTest.cs create mode 100644 test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/DeleteDNSFirewallClusterTest.cs create mode 100644 test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/ListDNSFirewallClustersTest.cs create mode 100644 test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/UpdateDNSFirewallClusterTest.cs diff --git a/src/Extensions/Cloudflare.Dns/DnsFirewallExtensions.cs b/src/Extensions/Cloudflare.Dns/DnsFirewallExtensions.cs new file mode 100644 index 0000000..cc7332b --- /dev/null +++ b/src/Extensions/Cloudflare.Dns/DnsFirewallExtensions.cs @@ -0,0 +1,154 @@ +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare.Dns.Internals; + +namespace AMWD.Net.Api.Cloudflare.Dns +{ + /// + /// Extensions for the DNS Firewall. + /// + public static class DnsFirewallExtensions + { + /// + /// List DNS Firewall clusters for an account. + /// + /// The instance. + /// The account identifier. + /// Filter options (optional). + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task>> ListDNSFirewallClusters(this ICloudflareClient client, string accountId, ListDNSFirewallClustersFilter? options = null, CancellationToken cancellationToken = default) + { + accountId.ValidateCloudflareId(); + + return client.GetAsync>($"/accounts/{accountId}/dns_firewall", options, cancellationToken); + } + + /// + /// Show a single DNS Firewall cluster for an account. + /// + /// The instance. + /// The account identifier. + /// The DNS firewall identifier. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> DNSFirewallClusterDetails(this ICloudflareClient client, string accountId, string dnsFirewallId, CancellationToken cancellationToken = default) + { + accountId.ValidateCloudflareId(); + dnsFirewallId.ValidateCloudflareId(); + + return client.GetAsync($"/accounts/{accountId}/dns_firewall/{dnsFirewallId}", null, cancellationToken); + } + + /// + /// Create a DNS Firewall cluster. + /// + /// The instance. + /// The request. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> CreateDNSFirewallCluster(this ICloudflareClient client, CreateDNSFirewallClusterRequest request, CancellationToken cancellationToken = default) + { + request.AccountId.ValidateCloudflareId(); + + if (string.IsNullOrWhiteSpace(request.Name)) + throw new ArgumentException("DNS Firewall cluster name must be provided.", nameof(request.Name)); + + request.Name = request.Name.Trim(); + + if (request.Name.Length > 160) + throw new ArgumentException("DNS Firewall cluster name must not exceed 160 characters.", nameof(request.Name)); + + if (request.MaximumCacheTtl.HasValue && (request.MaximumCacheTtl < 30 || 36000 < request.MaximumCacheTtl)) + throw new ArgumentOutOfRangeException(nameof(request.MaximumCacheTtl), "Maximum cache TTL must be between 30 and 36000."); + + if (request.MinimumCacheTtl.HasValue && (request.MinimumCacheTtl < 30 || 36000 < request.MinimumCacheTtl)) + throw new ArgumentOutOfRangeException(nameof(request.MinimumCacheTtl), "Minimum cache TTL must be between 30 and 36000."); + + if (request.NegativeCacheTtl.HasValue && (request.NegativeCacheTtl < 30 || 36000 < request.NegativeCacheTtl)) + throw new ArgumentOutOfRangeException(nameof(request.NegativeCacheTtl), "Negative cache TTL must be between 30 and 36000."); + + if (request.RateLimit.HasValue && (request.RateLimit < 100 || 1_000_000_000 < request.RateLimit)) + throw new ArgumentOutOfRangeException(nameof(request.RateLimit), "Ratelimit must be between 100 and 1,000,000,000 seconds."); + + if (request.Retries.HasValue && (request.Retries < 0 || 2 < request.Retries)) + throw new ArgumentOutOfRangeException(nameof(request.Retries), "Retries must be between 0 and 2."); + + var req = new InternalDNSFirewallClusterRequest + { + Name = request.Name, + UpstreamIps = request.UpstreamIps, + AttackMitigation = request.AttackMitigation, + DeprecateAnyRequests = request.DeprecateAnyRequests, + EcsFallback = request.EcsFallback, + MaximumCacheTtl = request.MaximumCacheTtl, + MinimumCacheTtl = request.MinimumCacheTtl, + NegativeCacheTtl = request.NegativeCacheTtl, + RateLimit = request.RateLimit, + Retries = request.Retries + }; + + return client.PostAsync($"/accounts/{request.AccountId}/dns_firewall", req, null, cancellationToken); + } + + /// + /// Modify the configuration of a DNS Firewall cluster. + /// + /// The instance. + /// The request. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> UpdateDNSFirewallCluster(this ICloudflareClient client, UpdateDNSFirewallClusterRequest request, CancellationToken cancellationToken = default) + { + request.AccountId.ValidateCloudflareId(); + request.DnsFirewallId.ValidateCloudflareId(); + + request.Name = request.Name?.Trim(); + + if (request.Name?.Length > 160) + throw new ArgumentException("DNS Firewall cluster name must not exceed 160 characters.", nameof(request.Name)); + + if (request.MaximumCacheTtl.HasValue && (request.MaximumCacheTtl < 30 || 36000 < request.MaximumCacheTtl)) + throw new ArgumentOutOfRangeException(nameof(request.MaximumCacheTtl), "Maximum cache TTL must be between 30 and 36000."); + + if (request.MinimumCacheTtl.HasValue && (request.MinimumCacheTtl < 30 || 36000 < request.MinimumCacheTtl)) + throw new ArgumentOutOfRangeException(nameof(request.MinimumCacheTtl), "Minimum cache TTL must be between 30 and 36000."); + + if (request.NegativeCacheTtl.HasValue && (request.NegativeCacheTtl < 30 || 36000 < request.NegativeCacheTtl)) + throw new ArgumentOutOfRangeException(nameof(request.NegativeCacheTtl), "Negative cache TTL must be between 30 and 36000."); + + if (request.RateLimit.HasValue && (request.RateLimit < 100 || 1_000_000_000 < request.RateLimit)) + throw new ArgumentOutOfRangeException(nameof(request.RateLimit), "Ratelimit must be between 100 and 1,000,000,000 seconds."); + + if (request.Retries.HasValue && (request.Retries < 0 || 2 < request.Retries)) + throw new ArgumentOutOfRangeException(nameof(request.Retries), "Retries must be between 0 and 2."); + + var req = new InternalDNSFirewallClusterRequest + { + Name = request.Name, + UpstreamIps = request.UpstreamIps, + AttackMitigation = request.AttackMitigation, + DeprecateAnyRequests = request.DeprecateAnyRequests, + EcsFallback = request.EcsFallback, + MaximumCacheTtl = request.MaximumCacheTtl, + MinimumCacheTtl = request.MinimumCacheTtl, + NegativeCacheTtl = request.NegativeCacheTtl, + RateLimit = request.RateLimit, + Retries = request.Retries + }; + + return client.PatchAsync($"/accounts/{request.AccountId}/dns_firewall", req, cancellationToken); + } + + /// + /// Delete a DNS Firewall cluster. + /// + /// The instance. + /// The account identifier. + /// The DNS firewall identifier. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> DeleteDNSFirewallCluster(this ICloudflareClient client, string accountId, string dnsFirewallId, CancellationToken cancellationToken = default) + { + accountId.ValidateCloudflareId(); + dnsFirewallId.ValidateCloudflareId(); + + return client.DeleteAsync($"/accounts/{accountId}/dns_firewall/{dnsFirewallId}", null, cancellationToken); + } + } +} diff --git a/src/Extensions/Cloudflare.Dns/Filters/ListDNSFirewallClustersFilter.cs b/src/Extensions/Cloudflare.Dns/Filters/ListDNSFirewallClustersFilter.cs new file mode 100644 index 0000000..b03a6a7 --- /dev/null +++ b/src/Extensions/Cloudflare.Dns/Filters/ListDNSFirewallClustersFilter.cs @@ -0,0 +1,32 @@ +namespace AMWD.Net.Api.Cloudflare.Dns +{ + /// + /// Represents a filter for querying DNS firewall clusters with optional pagination parameters. + /// + public class ListDNSFirewallClustersFilter : IQueryParameterFilter + { + /// + /// Page number of paginated results. + /// + public int? Page { get; set; } + + /// + /// Number of clusters per page. + /// + public int? PerPage { get; set; } + + /// + public IReadOnlyDictionary GetQueryParameters() + { + var dict = new Dictionary(); + + if (Page.HasValue && 1 <= Page.Value) + dict.Add("page", Page.Value.ToString()); + + if (PerPage.HasValue && 1 <= PerPage.Value && PerPage.Value <= 100) + dict.Add("per_page", PerPage.Value.ToString()); + + return dict; + } + } +} diff --git a/src/Extensions/Cloudflare.Dns/Internals/InternalDNSFirewallClusterRequest.cs b/src/Extensions/Cloudflare.Dns/Internals/InternalDNSFirewallClusterRequest.cs new file mode 100644 index 0000000..368e634 --- /dev/null +++ b/src/Extensions/Cloudflare.Dns/Internals/InternalDNSFirewallClusterRequest.cs @@ -0,0 +1,35 @@ +namespace AMWD.Net.Api.Cloudflare.Dns.Internals +{ + internal class InternalDNSFirewallClusterRequest + { + [JsonProperty("name")] + public string? Name { get; set; } + + [JsonProperty("upstream_ips")] + public IReadOnlyCollection? UpstreamIps { get; set; } + + [JsonProperty("attack_mitigation")] + public AttackMitigation? AttackMitigation { get; set; } + + [JsonProperty("deprecate_any_requests")] + public bool? DeprecateAnyRequests { get; set; } + + [JsonProperty("ecs_fallback")] + public bool? EcsFallback { get; set; } + + [JsonProperty("maximum_cache_ttl")] + public int? MaximumCacheTtl { get; set; } + + [JsonProperty("minimum_cache_ttl")] + public int? MinimumCacheTtl { get; set; } + + [JsonProperty("negative_cache_ttl")] + public int? NegativeCacheTtl { get; set; } + + [JsonProperty("ratelimit")] + public int? RateLimit { get; set; } + + [JsonProperty("retries")] + public int? Retries { get; set; } + } +} diff --git a/src/Extensions/Cloudflare.Dns/Models/DnsFirewallCluster.cs b/src/Extensions/Cloudflare.Dns/Models/DnsFirewallCluster.cs new file mode 100644 index 0000000..f78c376 --- /dev/null +++ b/src/Extensions/Cloudflare.Dns/Models/DnsFirewallCluster.cs @@ -0,0 +1,140 @@ +namespace AMWD.Net.Api.Cloudflare.Dns +{ + /// + /// Represents the response data for a DNS Firewall configuration on cloudflare. + /// + public class DnsFirewallCluster + { + /// + /// The identifier. + /// + [JsonProperty("id")] + public string? Id { get; set; } + + /// + /// Whether to refuse to answer queries for the ANY type. + /// + [JsonProperty("deprecate_any_requests")] + public bool? DeprecateAnyRequests { get; set; } + + /// + /// List of IPs used by DNS Firewall cluster. + /// + [JsonProperty("dns_firewall_ips")] + public IReadOnlyCollection? DnsFirewallIps { get; set; } + + /// + /// Whether to forward client IP (resolver) subnet if no EDNS Client Subnet is sent. + /// + [JsonProperty("ecs_fallback")] + public bool? EcsFallback { get; set; } + + /// + /// + /// By default, Cloudflare attempts to cache responses for as long as indicated by + /// the TTL received from upstream nameservers.This setting sets an upper bound on + /// this duration.For caching purposes, higher TTLs will be decreased to the + /// maximum value defined by this setting. + /// + /// + /// This setting does not affect the TTL value in the DNS response Cloudflare + /// returns to clients.Cloudflare will always forward the TTL value received from + /// upstream nameservers. + /// + /// + [JsonProperty("maximum_cache_ttl")] + public int? MaximumCacheTtl { get; set; } + + /// + /// + /// By default, Cloudflare attempts to cache responses for as long as indicated by + /// the TTL received from upstream nameservers.This setting sets a lower bound on + /// this duration.For caching purposes, lower TTLs will be increased to the minimum + /// value defined by this setting. + /// + /// + /// This setting does not affect the TTL value in the DNS response Cloudflare + /// returns to clients.Cloudflare will always forward the TTL value received from + /// upstream nameservers. + /// + /// + /// + /// Note that, even with this setting, there is no guarantee that a response will be + /// cached for at least the specified duration.Cached responses may be removed + /// earlier for capacity or other operational reasons. + /// + [JsonProperty("minimum_cache_ttl")] + public int? MinimumCacheTtl { get; set; } + + /// + /// Last modification of DNS Firewall cluster + /// + [JsonProperty("modified_on")] + public DateTime? ModifiedOn { get; set; } + + /// + /// DNS Firewall cluster name. + /// + [JsonProperty("name")] + public string? Name { get; set; } + + /// + /// + /// This setting controls how long DNS Firewall should cache negative responses + /// (e.g., NXDOMAIN) from the upstream servers. + /// + /// + /// This setting does not affect the TTL value in the DNS response Cloudflare + /// returns to clients.Cloudflare will always forward the TTL value received from + /// upstream nameservers. + /// + /// + [JsonProperty("negative_cache_ttl")] + public int? NegativeCacheTtl { get; set; } + + /// + /// Ratelimit in queries per second per datacenter + /// (applies to DNS queries sent to the upstream nameservers configured on the cluster). + /// + [JsonProperty("ratelimit")] + public int? RateLimit { get; set; } + + /// + /// Number of retries for fetching DNS responses from upstream nameservers + /// (not counting the initial attempt). + /// + [JsonProperty("retries")] + public int? Retries { get; set; } + + /// + /// Upstream DNS server IPs. + /// + [JsonProperty("upstream_ips")] + public IReadOnlyCollection? UpstreamIps { get; set; } + + /// + /// Attack mitigation settings. + /// + [JsonProperty("attack_mitigation")] + public AttackMitigation? AttackMitigation { get; set; } + } + + /// + /// Attack mitigation settings. + /// Source + /// + public class AttackMitigation + { + /// + /// When enabled, automatically mitigate random-prefix attacks to protect upstream DNS servers. + /// + [JsonProperty("enabled")] + public bool? Enabled { get; set; } + + /// + /// Only mitigate attacks when upstream servers seem unhealthy. + /// + [JsonProperty("only_when_upstream_unhealthy")] + public bool? OnlyWhenUpstreamUnhealthy { get; set; } + } +} diff --git a/src/Extensions/Cloudflare.Dns/README.md b/src/Extensions/Cloudflare.Dns/README.md index 8c2ce02..1a75866 100644 --- a/src/Extensions/Cloudflare.Dns/README.md +++ b/src/Extensions/Cloudflare.Dns/README.md @@ -113,6 +113,15 @@ This package contains the feature set of the _DNS_ section of the Cloudflare API - [Delete TSIG](https://developers.cloudflare.com/api/resources/dns/subresources/zone_transfers/subresources/tsigs/methods/delete/) +### [DNS Firewall] + +- [List DNS Firewall Clusters](https://developers.cloudflare.com/api/resources/dns_firewall/methods/list/) +- [DNS Firewall Cluster Details](https://developers.cloudflare.com/api/resources/dns_firewall/methods/get/) +- [Create DNS Firewall Cluster](https://developers.cloudflare.com/api/resources/dns_firewall/methods/create/) +- [Update DNS Firewall Cluster](https://developers.cloudflare.com/api/resources/dns_firewall/methods/update/) +- [Delete DNS Firewall Cluster](https://developers.cloudflare.com/api/resources/dns_firewall/methods/delete/) + + --- Published under MIT License (see [choose a license]) @@ -136,3 +145,4 @@ Published under MIT License (see [choose a license]) [Outgoing]: https://developers.cloudflare.com/api/resources/dns/subresources/zone_transfers/subresources/outgoing/ [Peers]: https://developers.cloudflare.com/api/resources/dns/subresources/zone_transfers/subresources/peers/ [TSIGs]: https://developers.cloudflare.com/api/resources/dns/subresources/zone_transfers/subresources/tsigs/ +[DNS Firewall]: https://developers.cloudflare.com/api/resources/dns_firewall/ diff --git a/src/Extensions/Cloudflare.Dns/Requests/CreateDNSFirewallClusterRequest.cs b/src/Extensions/Cloudflare.Dns/Requests/CreateDNSFirewallClusterRequest.cs new file mode 100644 index 0000000..a520101 --- /dev/null +++ b/src/Extensions/Cloudflare.Dns/Requests/CreateDNSFirewallClusterRequest.cs @@ -0,0 +1,24 @@ +namespace AMWD.Net.Api.Cloudflare.Dns +{ + /// + /// Represents a request to create a DNS Firewall cluster with specific configuration settings. + /// + public class CreateDNSFirewallClusterRequest : DNSFirewallClusterRequestBase + { + /// + /// Initializes a new instance of the class. + /// + /// The account identifier. + /// DNS Firewall cluster name. + public CreateDNSFirewallClusterRequest(string accountId, string name) + : base(accountId) + { + Name = name; + } + + /// + /// DNS Firewall cluster name. + /// + public string Name { get; set; } + } +} diff --git a/src/Extensions/Cloudflare.Dns/Requests/DNSFirewallClusterRequestBase.cs b/src/Extensions/Cloudflare.Dns/Requests/DNSFirewallClusterRequestBase.cs new file mode 100644 index 0000000..8d0f5ff --- /dev/null +++ b/src/Extensions/Cloudflare.Dns/Requests/DNSFirewallClusterRequestBase.cs @@ -0,0 +1,93 @@ +namespace AMWD.Net.Api.Cloudflare.Dns +{ + /// + /// Represents a request to create a DNS Firewall cluster with specific configuration settings. + /// + public abstract class DNSFirewallClusterRequestBase + { + /// + /// Initializes a new instance of the class. + /// + /// The account identifier. + public DNSFirewallClusterRequestBase(string accountId) + { + AccountId = accountId; + } + + /// + /// The account identifier. + /// + public string AccountId { get; set; } + + /// + /// Upstream DNS server IPs. + /// + public IReadOnlyCollection? UpstreamIps { get; set; } + + /// + /// Attack mitigation settings. + /// + public AttackMitigation? AttackMitigation { get; set; } + + /// + /// Whether to refuse to answer queries for the ANY type. + /// + public bool? DeprecateAnyRequests { get; set; } + + /// + /// Whether to forward client IP (resolver) subnet if no EDNS Client Subnet is sent. + /// + public bool? EcsFallback { get; set; } + + /// + /// + /// By default, Cloudflare attempts to cache responses for as long as indicated by the TTL received from upstream nameservers. + /// This setting sets an upper bound on this duration. + /// For caching purposes, higher TTLs will be decreased to the maximum value defined by this setting. + /// + /// + /// This setting does not affect the TTL value in the DNS response Cloudflare returns to clients. + /// Cloudflare will always forward the TTL value received from upstream nameservers. + /// + /// + public int? MaximumCacheTtl { get; set; } + + /// + /// + /// By default, Cloudflare attempts to cache responses for as long as indicated by the TTL received from upstream nameservers. + /// This setting sets a lower bound on this duration. + /// For caching purposes, lower TTLs will be increased to the minimum value defined by this setting. + /// + /// + /// This setting does not affect the TTL value in the DNS response Cloudflare returns to clients. + /// Cloudflare will always forward the TTL value received from upstream nameservers. + /// + /// + /// + /// Note that, even with this setting, there is no guarantee that a response will be cached for at least the specified duration. + /// Cached responses may be removed earlier for capacity or other operational reasons. + /// + public int? MinimumCacheTtl { get; set; } + + /// + /// + /// This setting controls how long DNS Firewall should cache negative responses (e.g., NXDOMAIN) from the upstream servers. + /// + /// + /// This setting does not affect the TTL value in the DNS response Cloudflare returns to clients. + /// Cloudflare will always forward the TTL value received from upstream nameservers. + /// + /// + public int? NegativeCacheTtl { get; set; } + + /// + /// Ratelimit in queries per second per datacenter (applies to DNS queries sent to the upstream nameservers configured on the cluster). + /// + public int? RateLimit { get; set; } + + /// + /// Number of retries for fetching DNS responses from upstream nameservers (not counting the initial attempt). + /// + public int? Retries { get; set; } + } +} diff --git a/src/Extensions/Cloudflare.Dns/Requests/UpdateDNSFirewallClusterRequest.cs b/src/Extensions/Cloudflare.Dns/Requests/UpdateDNSFirewallClusterRequest.cs new file mode 100644 index 0000000..2d6baa5 --- /dev/null +++ b/src/Extensions/Cloudflare.Dns/Requests/UpdateDNSFirewallClusterRequest.cs @@ -0,0 +1,29 @@ +namespace AMWD.Net.Api.Cloudflare.Dns +{ + /// + /// Represents a request to create a DNS Firewall cluster with specific configuration settings. + /// + public class UpdateDNSFirewallClusterRequest : DNSFirewallClusterRequestBase + { + /// + /// Initializes a new instance of the class. + /// + /// The account identifier. + /// DNS Firewall cluster name. + public UpdateDNSFirewallClusterRequest(string accountId, string dnsFirewallId) + : base(accountId) + { + DnsFirewallId = dnsFirewallId; + } + + /// + /// The DNS firewall cluster identifier. + /// + public string DnsFirewallId { get; set; } + + /// + /// DNS Firewall cluster name. + /// + public string? Name { get; set; } + } +} diff --git a/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/CreateDNSFirewallClusterTest.cs b/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/CreateDNSFirewallClusterTest.cs new file mode 100644 index 0000000..173de06 --- /dev/null +++ b/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/CreateDNSFirewallClusterTest.cs @@ -0,0 +1,250 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Dns; +using AMWD.Net.Api.Cloudflare.Dns.Internals; +using Moq; + +namespace Cloudflare.Dns.Tests.DnsFirewallExtensions +{ + [TestClass] + public class CreateDNSFirewallClusterTest + { + public TestContext TestContext { get; set; } + + private const string AccountId = "023e105f4ecef8ad9ca31a8372d0c353"; + private const string ClusterId = "023e105f4ecef8ad9ca31a8372d0c355"; + + private Mock _clientMock; + private CloudflareResponse _response; + private List<(string RequestPath, InternalDNSFirewallClusterRequest Request)> _callbacks; + private CreateDNSFirewallClusterRequest _request; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [new ResponseInfo(1000, "Message 1")], + Errors = [new ResponseInfo(1000, "Error 1")], + Result = new DnsFirewallCluster + { + Id = ClusterId, + Name = "example-cluster" + } + }; + + _request = new CreateDNSFirewallClusterRequest(AccountId, "example-cluster") + { + UpstreamIps = ["192.0.2.1"], + AttackMitigation = new AttackMitigation { Enabled = true, OnlyWhenUpstreamUnhealthy = false }, + DeprecateAnyRequests = true, + EcsFallback = false, + MaximumCacheTtl = 3600, + MinimumCacheTtl = 60, + NegativeCacheTtl = 120, + RateLimit = 1000, + Retries = 1 + }; + } + + [TestMethod] + public async Task ShouldCreateDnsFirewallCluster() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.CreateDNSFirewallCluster(_request, TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.HasCount(1, _callbacks); + + var (requestPath, request) = _callbacks.First(); + Assert.AreEqual($"/accounts/{AccountId}/dns_firewall", requestPath); + + Assert.IsNotNull(request); + Assert.AreEqual(_request.Name, request.Name); + CollectionAssert.AreEqual(_request.UpstreamIps?.ToList(), request.UpstreamIps?.ToList()); + Assert.IsNotNull(request.AttackMitigation); + Assert.AreEqual(_request.AttackMitigation?.Enabled, request.AttackMitigation?.Enabled); + Assert.AreEqual(_request.AttackMitigation?.OnlyWhenUpstreamUnhealthy, request.AttackMitigation?.OnlyWhenUpstreamUnhealthy); + Assert.AreEqual(_request.DeprecateAnyRequests, request.DeprecateAnyRequests); + Assert.AreEqual(_request.EcsFallback, request.EcsFallback); + Assert.AreEqual(_request.MaximumCacheTtl, request.MaximumCacheTtl); + Assert.AreEqual(_request.MinimumCacheTtl, request.MinimumCacheTtl); + Assert.AreEqual(_request.NegativeCacheTtl, request.NegativeCacheTtl); + Assert.AreEqual(_request.RateLimit, request.RateLimit); + Assert.AreEqual(_request.Retries, request.Retries); + + _clientMock.Verify(m => m.PostAsync($"/accounts/{AccountId}/dns_firewall", It.IsAny(), null, TestContext.CancellationToken), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldCreateDnsFirewallClusterMinimalSet() + { + // Arrange + _request = new CreateDNSFirewallClusterRequest(AccountId, "example-cluster"); + var client = GetClient(); + + // Act + var response = await client.CreateDNSFirewallCluster(_request, TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.HasCount(1, _callbacks); + + var (requestPath, request) = _callbacks.First(); + Assert.AreEqual($"/accounts/{AccountId}/dns_firewall", requestPath); + + Assert.IsNotNull(request); + Assert.AreEqual(_request.Name, request.Name); + Assert.IsNull(request.UpstreamIps); + Assert.IsNull(request.AttackMitigation); + Assert.IsNull(request.DeprecateAnyRequests); + Assert.IsNull(request.EcsFallback); + Assert.IsNull(request.MaximumCacheTtl); + Assert.IsNull(request.MinimumCacheTtl); + Assert.IsNull(request.NegativeCacheTtl); + Assert.IsNull(request.RateLimit); + Assert.IsNull(request.Retries); + + _clientMock.Verify(m => m.PostAsync($"/accounts/{AccountId}/dns_firewall", It.IsAny(), null, TestContext.CancellationToken), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public async Task ShouldThrowArgumentExceptionWhenNameIsNullOrWhitespace(string name) + { + // Arrange + _request.Name = name; + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await client.CreateDNSFirewallCluster(_request, TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task ShouldThrowArgumentExceptionWhenNameTooLong() + { + // Arrange + _request.Name = new string('a', 161); + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await client.CreateDNSFirewallCluster(_request, TestContext.CancellationToken); + }); + } + + [TestMethod] + [DataRow(29)] + [DataRow(36001)] + public async Task ShouldThrowArgumentOutOfRangeForMaximumCacheTtl(int invalid) + { + // Arrange + _request.MaximumCacheTtl = invalid; + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await client.CreateDNSFirewallCluster(_request, TestContext.CancellationToken); + }); + } + + [TestMethod] + [DataRow(29)] + [DataRow(36001)] + public async Task ShouldThrowArgumentOutOfRangeForMinimumCacheTtl(int invalid) + { + // Arrange + _request.MinimumCacheTtl = invalid; + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await client.CreateDNSFirewallCluster(_request, TestContext.CancellationToken); + }); + } + + [TestMethod] + [DataRow(29)] + [DataRow(36001)] + public async Task ShouldThrowArgumentOutOfRangeForNegativeCacheTtl(int invalid) + { + // Arrange + _request.NegativeCacheTtl = invalid; + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await client.CreateDNSFirewallCluster(_request, TestContext.CancellationToken); + }); + } + + [TestMethod] + [DataRow(99)] + [DataRow(1_000_000_001)] + public async Task ShouldThrowArgumentOutOfRangeForRateLimit(int invalid) + { + // Arrange + _request.RateLimit = invalid; + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await client.CreateDNSFirewallCluster(_request, TestContext.CancellationToken); + }); + } + + [TestMethod] + [DataRow(-1)] + [DataRow(3)] + public async Task ShouldThrowArgumentOutOfRangeForRetries(int invalid) + { + // Arrange + _request.Retries = invalid; + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await client.CreateDNSFirewallCluster(_request, TestContext.CancellationToken); + }); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.PostAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, request, _, _) => _callbacks.Add((requestPath, request))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/DNSFirewallClusterDetailsTest.cs b/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/DNSFirewallClusterDetailsTest.cs new file mode 100644 index 0000000..729bca2 --- /dev/null +++ b/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/DNSFirewallClusterDetailsTest.cs @@ -0,0 +1,97 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Dns; +using Moq; + +namespace Cloudflare.Dns.Tests.DnsFirewallExtensions +{ + [TestClass] + public class DNSFirewallClusterDetailsTest + { + public TestContext TestContext { get; set; } + + private const string AccountId = "023e105f4ecef8ad9ca31a8372d0c353"; + private const string ClusterId = "023e105f4ecef8ad9ca31a8372d0c355"; + + private Mock _clientMock; + private CloudflareResponse _response; + private List<(string RequestPath, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = + [ + new ResponseInfo(1000, "Message 1") + ], + Errors = + [ + new ResponseInfo(1000, "Error 1") + ], + Result = new DnsFirewallCluster + { + Id = ClusterId, + Name = "example-cluster", + ModifiedOn = DateTime.Parse("2025-01-01T12:00:00Z"), + DeprecateAnyRequests = true, + EcsFallback = false, + MaximumCacheTtl = 3600, + MinimumCacheTtl = 60, + NegativeCacheTtl = 30, + RateLimit = 1000, + Retries = 2, + DnsFirewallIps = ["10.0.0.1"], + UpstreamIps = ["192.0.2.1"], + AttackMitigation = new AttackMitigation { Enabled = true, OnlyWhenUpstreamUnhealthy = false } + } + }; + } + + [TestMethod] + public async Task ShouldGetDnsFirewallClusterDetails() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.DNSFirewallClusterDetails(AccountId, ClusterId, TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.IsNotNull(response.Result); + Assert.AreEqual(ClusterId, response.Result.Id); + Assert.AreEqual("example-cluster", response.Result.Name); + Assert.IsTrue(response.Result.DeprecateAnyRequests ?? false); + Assert.AreEqual(3600, response.Result.MaximumCacheTtl); + Assert.AreEqual(60, response.Result.MinimumCacheTtl); + + Assert.HasCount(1, _callbacks); + + var (requestPath, queryFilter) = _callbacks.First(); + Assert.AreEqual($"/accounts/{AccountId}/dns_firewall/{ClusterId}", requestPath); + Assert.IsNull(queryFilter); + + _clientMock.Verify(m => m.GetAsync($"/accounts/{AccountId}/dns_firewall/{ClusterId}", null, TestContext.CancellationToken), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.GetAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, queryFilter, _) => _callbacks.Add((requestPath, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/DeleteDNSFirewallClusterTest.cs b/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/DeleteDNSFirewallClusterTest.cs new file mode 100644 index 0000000..c381830 --- /dev/null +++ b/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/DeleteDNSFirewallClusterTest.cs @@ -0,0 +1,72 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Dns; +using Moq; + +namespace Cloudflare.Dns.Tests.DnsFirewallExtensions +{ + [TestClass] + public class DeleteDNSFirewallClusterTest + { + public TestContext TestContext { get; set; } + + private const string AccountId = "023e105f4ecef8ad9ca31a8372d0c353"; + private const string ClusterId = "023e105f4ecef8ad9ca31a8372d0c355"; + + private Mock _clientMock; + private CloudflareResponse _response; + private List<(string RequestPath, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [new ResponseInfo(1000, "Message 1")], + Errors = [new ResponseInfo(1000, "Error 1")], + Result = new Identifier { Id = ClusterId } + }; + } + + [TestMethod] + public async Task ShouldDeleteDnsFirewallCluster() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.DeleteDNSFirewallCluster(AccountId, ClusterId, TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.IsNotNull(response.Result); + Assert.AreEqual(ClusterId, response.Result.Id); + + Assert.HasCount(1, _callbacks); + + var (requestPath, queryFilter) = _callbacks.First(); + Assert.AreEqual($"/accounts/{AccountId}/dns_firewall/{ClusterId}", requestPath); + Assert.IsNull(queryFilter); + + _clientMock.Verify(m => m.DeleteAsync($"/accounts/{AccountId}/dns_firewall/{ClusterId}", null, TestContext.CancellationToken), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.DeleteAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, queryFilter, _) => _callbacks.Add((requestPath, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/ListDNSFirewallClustersTest.cs b/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/ListDNSFirewallClustersTest.cs new file mode 100644 index 0000000..ff9eaa5 --- /dev/null +++ b/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/ListDNSFirewallClustersTest.cs @@ -0,0 +1,211 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Dns; +using Moq; + +namespace Cloudflare.Dns.Tests.DnsFirewallExtensions +{ + [TestClass] + public class ListDNSFirewallClustersTest + { + public TestContext TestContext { get; set; } + + private const string AccountId = "023e105f4ecef8ad9ca31a8372d0c353"; + + private Mock _clientMock; + private CloudflareResponse> _response; + private List<(string RequestPath, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = new List<(string, IQueryParameterFilter)>(); + + _response = new CloudflareResponse> + { + Success = true, + Messages = + [ + new ResponseInfo(1000, "Message 1") + ], + Errors = + [ + new ResponseInfo(1000, "Error 1") + ], + ResultInfo = new PaginationInfo + { + Count = 1, + Page = 1, + PerPage = 20, + TotalCount = 2000, + TotalPages = 100, + }, + Result = + [ + new DnsFirewallCluster + { + Id = "cluster-1", + Name = "example-cluster", + ModifiedOn = DateTime.Parse("2024-01-01T05:20:00.12345Z"), + DeprecateAnyRequests = true, + MaximumCacheTtl = 3600, + MinimumCacheTtl = 60, + RateLimit = 1000, + Retries = 2, + DnsFirewallIps = ["10.0.0.1"], + UpstreamIps = ["192.0.2.1"], + AttackMitigation = new AttackMitigation { Enabled = true, OnlyWhenUpstreamUnhealthy = false } + } + ] + }; + } + + [TestMethod] + public async Task ShouldListDNSFirewallClusters() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.ListDNSFirewallClusters(AccountId, cancellationToken: TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + + Assert.HasCount(1, response.Result); + Assert.AreEqual("cluster-1", response.Result.First().Id); + Assert.AreEqual("example-cluster", response.Result.First().Name); + + Assert.HasCount(1, _callbacks); + + var (requestPath, queryFilter) = _callbacks.First(); + Assert.AreEqual($"/accounts/{AccountId}/dns_firewall", requestPath); + Assert.IsNull(queryFilter); + + _clientMock.Verify(m => m.GetAsync>($"/accounts/{AccountId}/dns_firewall", null, TestContext.CancellationToken), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldListDNSFirewallClustersWithFilter() + { + // Arrange + var filter = new ListDNSFirewallClustersFilter + { + Page = 2, + PerPage = 10 + }; + + var client = GetClient(); + + // Act + var response = await client.ListDNSFirewallClusters(AccountId, filter, TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + + Assert.HasCount(1, response.Result); + Assert.AreEqual("cluster-1", response.Result.First().Id); + Assert.AreEqual("example-cluster", response.Result.First().Name); + + Assert.HasCount(1, _callbacks); + + var (requestPath, queryFilter) = _callbacks.First(); + Assert.AreEqual($"/accounts/{AccountId}/dns_firewall", requestPath); + Assert.IsNotNull(queryFilter); + + Assert.IsInstanceOfType(queryFilter); + Assert.AreEqual(2, ((ListDNSFirewallClustersFilter)queryFilter).Page); + Assert.AreEqual(10, ((ListDNSFirewallClustersFilter)queryFilter).PerPage); + + _clientMock.Verify(m => m.GetAsync>($"/accounts/{AccountId}/dns_firewall", filter, TestContext.CancellationToken), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldReturnEmptyParameterList() + { + // Arrange + var filter = new ListDNSFirewallClustersFilter(); + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.IsEmpty(dict); + } + + [TestMethod] + public void ShouldReturnFullParameterList() + { + // Arrange + var filter = new ListDNSFirewallClustersFilter + { + Page = 2, + PerPage = 20 + }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.HasCount(2, dict); + + Assert.IsTrue(dict.ContainsKey("page")); + Assert.IsTrue(dict.ContainsKey("per_page")); + + Assert.AreEqual("2", dict["page"]); + Assert.AreEqual("20", dict["per_page"]); + } + + [TestMethod] + [DataRow(null)] + [DataRow(0)] + public void ShouldNotAddPage(int? page) + { + // Arrange + var filter = new ListDNSFirewallClustersFilter { Page = page }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.IsEmpty(dict); + } + + [TestMethod] + [DataRow(null)] + [DataRow(0)] + [DataRow(101)] + public void ShouldNotAddPerPage(int? perPage) + { + // Arrange + var filter = new ListDNSFirewallClustersFilter { PerPage = perPage }; + + // Act + var dict = filter.GetQueryParameters(); + + // Assert + Assert.IsNotNull(dict); + Assert.IsEmpty(dict); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.GetAsync>(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, queryFilter, _) => _callbacks.Add((requestPath, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/UpdateDNSFirewallClusterTest.cs b/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/UpdateDNSFirewallClusterTest.cs new file mode 100644 index 0000000..f577a8c --- /dev/null +++ b/test/Extensions/Cloudflare.Dns.Tests/DnsFirewallExtensions/UpdateDNSFirewallClusterTest.cs @@ -0,0 +1,250 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Dns; +using AMWD.Net.Api.Cloudflare.Dns.Internals; +using Moq; + +namespace Cloudflare.Dns.Tests.DnsFirewallExtensions +{ + [TestClass] + public class UpdateDNSFirewallClusterTest + { + public TestContext TestContext { get; set; } + + private const string AccountId = "023e105f4ecef8ad9ca31a8372d0c353"; + private const string ClusterId = "023e105f4ecef8ad9ca31a8372d0c355"; + + private Mock _clientMock; + private CloudflareResponse _response; + private List<(string RequestPath, InternalDNSFirewallClusterRequest Request)> _callbacks; + private UpdateDNSFirewallClusterRequest _request; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = [new ResponseInfo(1000, "Message 1")], + Errors = [new ResponseInfo(1000, "Error 1")], + Result = new DnsFirewallCluster + { + Id = ClusterId, + Name = "example-cluster" + } + }; + + _request = new UpdateDNSFirewallClusterRequest(AccountId, ClusterId) + { + Name = "example-cluster", + UpstreamIps = ["192.0.2.1"], + AttackMitigation = new AttackMitigation { Enabled = true, OnlyWhenUpstreamUnhealthy = false }, + DeprecateAnyRequests = true, + EcsFallback = false, + MaximumCacheTtl = 3600, + MinimumCacheTtl = 60, + NegativeCacheTtl = 120, + RateLimit = 1000, + Retries = 1 + }; + } + + [TestMethod] + public async Task ShouldUpdateDnsFirewallCluster() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.UpdateDNSFirewallCluster(_request, TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.HasCount(1, _callbacks); + + var (requestPath, request) = _callbacks.First(); + Assert.AreEqual($"/accounts/{AccountId}/dns_firewall", requestPath); + + Assert.IsNotNull(request); + Assert.AreEqual(_request.Name, request.Name); + CollectionAssert.AreEqual(_request.UpstreamIps?.ToList(), request.UpstreamIps?.ToList()); + Assert.IsNotNull(request.AttackMitigation); + Assert.AreEqual(_request.AttackMitigation?.Enabled, request.AttackMitigation?.Enabled); + Assert.AreEqual(_request.AttackMitigation?.OnlyWhenUpstreamUnhealthy, request.AttackMitigation?.OnlyWhenUpstreamUnhealthy); + Assert.AreEqual(_request.DeprecateAnyRequests, request.DeprecateAnyRequests); + Assert.AreEqual(_request.EcsFallback, request.EcsFallback); + Assert.AreEqual(_request.MaximumCacheTtl, request.MaximumCacheTtl); + Assert.AreEqual(_request.MinimumCacheTtl, request.MinimumCacheTtl); + Assert.AreEqual(_request.NegativeCacheTtl, request.NegativeCacheTtl); + Assert.AreEqual(_request.RateLimit, request.RateLimit); + Assert.AreEqual(_request.Retries, request.Retries); + + _clientMock.Verify(m => m.PatchAsync($"/accounts/{AccountId}/dns_firewall", It.IsAny(), TestContext.CancellationToken), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldUpdateDnsFirewallClusterMinimalSet() + { + // Arrange + _request = new UpdateDNSFirewallClusterRequest(AccountId, ClusterId); + var client = GetClient(); + + // Act + var response = await client.UpdateDNSFirewallCluster(_request, TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.HasCount(1, _callbacks); + + var (requestPath, request) = _callbacks.First(); + Assert.AreEqual($"/accounts/{AccountId}/dns_firewall", requestPath); + + Assert.IsNotNull(request); + Assert.IsNull(request.Name); + Assert.IsNull(request.UpstreamIps); + Assert.IsNull(request.AttackMitigation); + Assert.IsNull(request.DeprecateAnyRequests); + Assert.IsNull(request.EcsFallback); + Assert.IsNull(request.MaximumCacheTtl); + Assert.IsNull(request.MinimumCacheTtl); + Assert.IsNull(request.NegativeCacheTtl); + Assert.IsNull(request.RateLimit); + Assert.IsNull(request.Retries); + + _clientMock.Verify(m => m.PatchAsync($"/accounts/{AccountId}/dns_firewall", It.IsAny(), TestContext.CancellationToken), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldTrimName() + { + // Arrange + _request.Name = " example-trim "; + var client = GetClient(); + + // Act + await client.UpdateDNSFirewallCluster(_request, TestContext.CancellationToken); + + // Assert + Assert.HasCount(1, _callbacks); + var (_, request) = _callbacks.First(); + Assert.AreEqual("example-trim", request.Name); + } + + [TestMethod] + public async Task ShouldThrowArgumentExceptionWhenNameTooLong() + { + // Arrange + _request.Name = new string('a', 161); + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await client.UpdateDNSFirewallCluster(_request, TestContext.CancellationToken); + }); + } + + [TestMethod] + [DataRow(29)] + [DataRow(36001)] + public async Task ShouldThrowArgumentOutOfRangeForMaximumCacheTtl(int invalid) + { + // Arrange + _request.MaximumCacheTtl = invalid; + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await client.UpdateDNSFirewallCluster(_request, TestContext.CancellationToken); + }); + } + + [TestMethod] + [DataRow(29)] + [DataRow(36001)] + public async Task ShouldThrowArgumentOutOfRangeForMinimumCacheTtl(int invalid) + { + // Arrange + _request.MinimumCacheTtl = invalid; + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await client.UpdateDNSFirewallCluster(_request, TestContext.CancellationToken); + }); + } + + [TestMethod] + [DataRow(29)] + [DataRow(36001)] + public async Task ShouldThrowArgumentOutOfRangeForNegativeCacheTtl(int invalid) + { + // Arrange + _request.NegativeCacheTtl = invalid; + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await client.UpdateDNSFirewallCluster(_request, TestContext.CancellationToken); + }); + } + + [TestMethod] + [DataRow(99)] + [DataRow(1_000_000_001)] + public async Task ShouldThrowArgumentOutOfRangeForRateLimit(int invalid) + { + // Arrange + _request.RateLimit = invalid; + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await client.UpdateDNSFirewallCluster(_request, TestContext.CancellationToken); + }); + } + + [TestMethod] + [DataRow(-1)] + [DataRow(3)] + public async Task ShouldThrowArgumentOutOfRangeForRetries(int invalid) + { + // Arrange + _request.Retries = invalid; + var client = GetClient(); + + // Act & Assert + await Assert.ThrowsExactlyAsync(async () => + { + await client.UpdateDNSFirewallCluster(_request, TestContext.CancellationToken); + }); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.PatchAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, request, _) => _callbacks.Add((requestPath, request))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +}