From 9322f2ba6ae46b4dde595bfc3b10b58cb2b37508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 12 Nov 2024 08:13:06 +0100 Subject: [PATCH] Added zone security.txt functions --- Cloudflare/Cloudflare.csproj | 3 +- .../Cloudflare.Zones/Cloudflare.Zones.csproj | 3 +- .../InternalUpdateSecurityTxtRequest.cs | 35 ++++ .../Cloudflare.Zones/Models/SecurityTxt.cs | 65 +++++++ .../Requests/UpdateSecurityTxtRequest.cs | 100 ++++++++++ .../SecurityCenterExtensions.cs | 78 ++++++++ .../SecurityCenterTests.cs | 181 ++++++++++++++++++ 7 files changed, 463 insertions(+), 2 deletions(-) create mode 100644 Extensions/Cloudflare.Zones/Internals/Requests/InternalUpdateSecurityTxtRequest.cs create mode 100644 Extensions/Cloudflare.Zones/Models/SecurityTxt.cs create mode 100644 Extensions/Cloudflare.Zones/Requests/UpdateSecurityTxtRequest.cs create mode 100644 Extensions/Cloudflare.Zones/SecurityCenterExtensions.cs create mode 100644 UnitTests/Cloudflare.Zones.Tests/SecurityCenterTests.cs diff --git a/Cloudflare/Cloudflare.csproj b/Cloudflare/Cloudflare.csproj index 6716e16..406a3f6 100644 --- a/Cloudflare/Cloudflare.csproj +++ b/Cloudflare/Cloudflare.csproj @@ -32,7 +32,8 @@ Core features of the Cloudflare API - + + true diff --git a/Extensions/Cloudflare.Zones/Cloudflare.Zones.csproj b/Extensions/Cloudflare.Zones/Cloudflare.Zones.csproj index 4011bae..471b4b9 100644 --- a/Extensions/Cloudflare.Zones/Cloudflare.Zones.csproj +++ b/Extensions/Cloudflare.Zones/Cloudflare.Zones.csproj @@ -13,7 +13,8 @@ Zone management features of the Cloudflare API - + + true diff --git a/Extensions/Cloudflare.Zones/Internals/Requests/InternalUpdateSecurityTxtRequest.cs b/Extensions/Cloudflare.Zones/Internals/Requests/InternalUpdateSecurityTxtRequest.cs new file mode 100644 index 0000000..b1a950a --- /dev/null +++ b/Extensions/Cloudflare.Zones/Internals/Requests/InternalUpdateSecurityTxtRequest.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare.Zones.Internals.Requests +{ + internal class InternalUpdateSecurityTxtRequest + { + [JsonProperty("acknowledgements")] + public IList? Acknowledgements { get; set; } + + [JsonProperty("canonical")] + public IList? Canonical { get; set; } + + [JsonProperty("contact")] + public IList? Contact { get; set; } + + [JsonProperty("enabled")] + public bool? Enabled { get; set; } + + [JsonProperty("encryption")] + public IList? Encryption { get; set; } + + [JsonProperty("expires")] + public DateTime? Expires { get; set; } + + [JsonProperty("hiring")] + public IList? Hiring { get; set; } + + [JsonProperty("policy")] + public IList? Policy { get; set; } + + [JsonProperty("preferredLanguages")] + public string? PreferredLanguages { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/Models/SecurityTxt.cs b/Extensions/Cloudflare.Zones/Models/SecurityTxt.cs new file mode 100644 index 0000000..c5b19d8 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Models/SecurityTxt.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// The security TXT record. + /// + public class SecurityTxt + { + /// + /// The acknowledgements. + /// + [JsonProperty("acknowledgements")] + public IList? Acknowledgements { get; set; } + + /// + /// The canonical. + /// + [JsonProperty("canonical")] + public IList? Canonical { get; set; } + + /// + /// The contact. + /// + [JsonProperty("contact")] + public IList? Contact { get; set; } + + /// + /// A value indicating whether this security.txt is enabled. + /// + [JsonProperty("enabled")] + public bool? Enabled { get; set; } + + /// + ///The encryption. + /// + [JsonProperty("encryption")] + public IList? Encryption { get; set; } + + /// + /// The expiry. + /// + [JsonProperty("expires")] + public DateTime? Expires { get; set; } + + /// + /// The hiring. + /// + [JsonProperty("hiring")] + public IList? Hiring { get; set; } + + /// + /// The policies. + /// + [JsonProperty("policy")] + public IList? Policy { get; set; } + + /// + /// The preferred languages. + /// + [JsonProperty("preferredLanguages")] + public string? PreferredLanguages { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/Requests/UpdateSecurityTxtRequest.cs b/Extensions/Cloudflare.Zones/Requests/UpdateSecurityTxtRequest.cs new file mode 100644 index 0000000..09a4c53 --- /dev/null +++ b/Extensions/Cloudflare.Zones/Requests/UpdateSecurityTxtRequest.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// Request to update security.txt + /// + public class UpdateSecurityTxtRequest(string zoneId) + { + /// + /// The zone identifier. + /// + public string ZoneId { get; set; } = zoneId; + + /// + /// Gets or sets the acknowledgements. + /// + /// + /// Example: https://example.com/hall-of-fame.html + /// + public IList? Acknowledgements { get; set; } + + /// + /// Gets or sets the canonical. + /// + /// + /// Example: https://www.example.com/.well-known/security.txt + /// + public IList? Canonical { get; set; } + + /// + /// Gets or sets the contact. + /// + /// + /// Examples: + /// + /// mailto:security@example.com + /// tel:+1-201-555-0123 + /// https://example.com/security-contact.html + /// + /// + public IList? Contact { get; set; } + + /// + /// Gets or sets a value indicating whether this security.txt is enabled. + /// + public bool? Enabled { get; set; } + + /// + /// Gets or sets the encryption. + /// + /// + /// Examples: + /// + /// https://example.com/pgp-key.txt + /// dns:5d2d37ab76d47d36._openpgpkey.example.com?type=OPENPGPKEY + /// openpgp4fpr:5f2de5521c63a801ab59ccb603d49de44b29100f + /// + /// + public IList? Encryption { get; set; } + + /// + /// Gets or sets the expires. + /// + /// + /// NOTE: The value will be converted to UTC when the is not . + /// + public DateTime? Expires { get; set; } + + /// + /// Gets or sets the hiring. + /// + /// + /// Example: https://example.com/jobs.html + /// + public IList? Hiring { get; set; } + + /// + /// Gets or sets the policies. + /// + /// + /// Example: https://example.com/disclosure-policy.html + /// + public IList? Policy { get; set; } + + /// + /// Gets or sets the preferred languages. + /// + /// + /// Examples: + /// + /// en + /// es + /// fr + /// + /// + public IList? PreferredLanguages { get; set; } + } +} diff --git a/Extensions/Cloudflare.Zones/SecurityCenterExtensions.cs b/Extensions/Cloudflare.Zones/SecurityCenterExtensions.cs new file mode 100644 index 0000000..c82f87b --- /dev/null +++ b/Extensions/Cloudflare.Zones/SecurityCenterExtensions.cs @@ -0,0 +1,78 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare.Zones.Internals.Requests; + +namespace AMWD.Net.Api.Cloudflare.Zones +{ + /// + /// Extensions for security center section of a zone. + /// + public static class SecurityCenterExtensions + { + /// + /// Delete security.txt + /// + /// The . + /// The zone ID. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static async Task DeleteSecurityTxt(this ICloudflareClient client, string zoneId, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + return await client.DeleteAsync($"zones/{zoneId}/security-center/securitytxt", cancellationToken: cancellationToken); + } + + /// + /// Get security.txt. + /// + /// The . + /// The zone ID. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> GetSecurityTxt(this ICloudflareClient client, string zoneId, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + return client.GetAsync($"zones/{zoneId}/security-center/securitytxt", cancellationToken: cancellationToken); + } + + /// + /// Update security.txt + /// + /// The . + /// The request information. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static async Task UpdateSecurityTxt(this ICloudflareClient client, UpdateSecurityTxtRequest request, CancellationToken cancellationToken = default) + { + request.ZoneId.ValidateCloudflareId(); + + var req = new InternalUpdateSecurityTxtRequest(); + + if (request.Acknowledgements != null) + req.Acknowledgements = request.Acknowledgements.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); + + if (request.Canonical != null) + req.Canonical = request.Canonical.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); + + if (request.Contact != null) + req.Contact = request.Contact.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); + + req.Enabled = request.Enabled; + + if (request.Encryption != null) + req.Encryption = request.Encryption.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); + + if (request.Expires.HasValue) + req.Expires = request.Expires.Value.ToUniversalTime(); + + if (request.Hiring != null) + req.Hiring = request.Hiring.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); + + if (request.Policy != null) + req.Policy = request.Policy.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); + + if (request.PreferredLanguages != null) + req.PreferredLanguages = string.Join(", ", request.PreferredLanguages.Where(s => !string.IsNullOrWhiteSpace(s))); + + return await client.PutAsync($"zones/{request.ZoneId}/security-center/securitytxt", req, cancellationToken); + } + } +} diff --git a/UnitTests/Cloudflare.Zones.Tests/SecurityCenterTests.cs b/UnitTests/Cloudflare.Zones.Tests/SecurityCenterTests.cs new file mode 100644 index 0000000..e22e4aa --- /dev/null +++ b/UnitTests/Cloudflare.Zones.Tests/SecurityCenterTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Zones; +using AMWD.Net.Api.Cloudflare.Zones.Internals.Requests; +using Moq; + +namespace Cloudflare.Zones.Tests +{ + [TestClass] + public class SecurityCenterTests + { + private CultureInfo _currentCulture; + private CultureInfo _currentUICulture; + + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + + private Mock _clientMock; + + private CloudflareResponse _getResponse; + private CloudflareResponse _updateResponse; + private CloudflareResponse _deleteResponse; + + private List<(string RequestPath, IQueryParameterFilter QueryFilter)> _getCallbacks; + private List<(string RequestPath, InternalUpdateSecurityTxtRequest Request)> _updateCallbacks; + private List<(string RequestPath, IQueryParameterFilter QueryFilter)> _deleteCallbacks; + + private UpdateSecurityTxtRequest _request; + + [TestInitialize] + public void Initialize() + { + _getCallbacks = []; + _updateCallbacks = []; + _deleteCallbacks = []; + + _getResponse = new CloudflareResponse + { + Success = true, + Result = new SecurityTxt + { + Acknowledgements = ["https://example.com/hall-of-fame.html"], + Canonical = ["https://www.example.com/.well-known/security.txt"], + Contact = ["mailto:security@example.com"], + Enabled = true, + Encryption = ["https://example.com/pgp-key.txt"], + Expires = DateTime.Parse("2019-08-24T14:15:22Z"), + Hiring = ["https://example.com/jobs.html"], + Policy = ["https://example.com/disclosure-policy.html"], + PreferredLanguages = "de, en" + } + }; + _updateResponse = new CloudflareResponse { Success = true }; + _deleteResponse = new CloudflareResponse { Success = true }; + + _request = new UpdateSecurityTxtRequest(ZoneId) + { + Acknowledgements = ["https://example.com/hall-of-fame.html"], + Canonical = ["https://www.example.com/.well-known/security.txt"], + Contact = ["mailto:security@example.com"], + Enabled = true, + Encryption = ["https://example.com/pgp-key.txt"], + Expires = new DateTime(2024, 8, 1, 10, 20, 30, DateTimeKind.Unspecified), + Hiring = ["https://example.com/jobs.html"], + Policy = ["https://example.com/disclosure-policy.html"], + PreferredLanguages = ["de", "en", ""] + }; + + _currentCulture = Thread.CurrentThread.CurrentCulture; + _currentUICulture = Thread.CurrentThread.CurrentUICulture; + + Thread.CurrentThread.CurrentCulture = Thread.CurrentThread.CurrentUICulture = new CultureInfo("de-DE"); + } + + [TestCleanup] + public void Cleanup() + { + Thread.CurrentThread.CurrentCulture = _currentCulture; + Thread.CurrentThread.CurrentUICulture = _currentUICulture; + } + + [TestMethod] + public async Task ShouldGetSecurityTxt() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.GetSecurityTxt(ZoneId); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_getResponse.Result, response.Result); + + Assert.AreEqual(1, _getCallbacks.Count); + + var callback = _getCallbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/security-center/securitytxt", callback.RequestPath); + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.GetAsync($"zones/{ZoneId}/security-center/securitytxt", null, It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldUpdateSecurityTxt() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.UpdateSecurityTxt(_request); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + + Assert.AreEqual(1, _updateCallbacks.Count); + + var callback = _updateCallbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/security-center/securitytxt", callback.RequestPath); + Assert.IsNotNull(callback.Request); + CollectionAssert.AreEqual(_request.Acknowledgements.ToArray(), callback.Request.Acknowledgements.ToArray()); + CollectionAssert.AreEqual(_request.Canonical.ToArray(), callback.Request.Canonical.ToArray()); + CollectionAssert.AreEqual(_request.Contact.ToArray(), callback.Request.Contact.ToArray()); + Assert.AreEqual(_request.Enabled, callback.Request.Enabled); + CollectionAssert.AreEqual(_request.Encryption.ToArray(), callback.Request.Encryption.ToArray()); + Assert.AreEqual("01.08.2024 08:20:30 +0", callback.Request.Expires?.ToString("dd.MM.yyyy HH:mm:ss z")); + CollectionAssert.AreEqual(_request.Hiring.ToArray(), callback.Request.Hiring.ToArray()); + CollectionAssert.AreEqual(_request.Policy.ToArray(), callback.Request.Policy.ToArray()); + Assert.AreEqual("de, en", callback.Request.PreferredLanguages); + } + + [TestMethod] + public async Task ShouldDeleteSecurityTxt() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.DeleteSecurityTxt(ZoneId); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + + Assert.AreEqual(1, _deleteCallbacks.Count); + + var callback = _deleteCallbacks.First(); + Assert.AreEqual($"zones/{ZoneId}/security-center/securitytxt", callback.RequestPath); + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.DeleteAsync($"zones/{ZoneId}/security-center/securitytxt", null, It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.GetAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, queryFilter, _) => _getCallbacks.Add((requestPath, queryFilter))) + .ReturnsAsync(() => _getResponse); + _clientMock + .Setup(m => m.PutAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, queryFilter, _) => _updateCallbacks.Add((requestPath, queryFilter))) + .ReturnsAsync(() => _updateResponse); + _clientMock + .Setup(m => m.DeleteAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((requestPath, queryFilter, _) => _deleteCallbacks.Add((requestPath, queryFilter))) + .ReturnsAsync(() => _deleteResponse); + + return _clientMock.Object; + } + } +}