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;
+ }
+ }
+}