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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,11 @@
<EnablePackageValidation>true</EnablePackageValidation>
</PropertyGroup>

<!-- SourceLink Package -->
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
<!--
SourceLink Package - intentionally absent.
The .NET 8+ SDK bundles Microsoft.SourceLink.GitHub, so an explicit PackageReference is redundant.
The explicit 8.0.0 reference was removed on 2026-09-19 because Microsoft.Build.Tasks.Git 8.0.0 carries
advisory GHSA-23fw-v26w-5fgq (CVE-2026-62900) with no patched 8.x release, and NuGet audit fails the build.
-->

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,9 @@ private async Task RunCustomDomainLifecycleAsync(string bucketName, string zoneI
// 4. Get Custom Domain Status.
logger.LogInformation("Getting status for custom domain: {Hostname}", hostname);
var statusResult = await cf.Accounts.Buckets.GetCustomDomainStatusAsync(bucketName, hostname);
logger.LogInformation("Got status. Domain: {Domain}, Status: {Status}", statusResult.Domain, statusResult.Status);
// The hostname only serves traffic once the certificate status (SslStatus) is "active"; ownership alone is not enough.
logger.LogInformation("Got status. Domain: {Domain}, Ownership: {Status}, Certificate: {SslStatus}",
statusResult.Domain, statusResult.Status, statusResult.SslStatus ?? "not reported");

// 5. Update Custom Domain (set minimum TLS version).
logger.LogInformation("Updating custom domain {Hostname} to require TLS 1.2", hostname);
Expand Down
28 changes: 26 additions & 2 deletions src/Cloudflare.NET/Accounts/Models/CustomDomainModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,25 @@ IReadOnlyList<CustomDomain> Domains
/// Represents the response from attaching or querying a custom domain. The custom converter handles the
/// polymorphic 'status' field. The EdgeHostname may be null in some responses.
/// </summary>
/// <param name="Domain">The custom domain hostname (e.g., "files.example.com").</param>
/// <param name="EdgeHostname">The Cloudflare edge hostname the domain resolves to, when the API reports it.</param>
/// <param name="Status">
/// The hostname ownership status. When the API returns the nested status object (GET), this is its
/// <c>ownership</c> value; when the API returns a plain string (POST/PUT), this is that string.
/// </param>
/// <param name="SslStatus">
/// The certificate status of the custom domain, taken from the nested status object's <c>ssl</c> value
/// (documented values: "initializing", "pending", "active", "deactivated", "error", "unknown"). A hostname that
/// no wildcard certificate covers only serves traffic once this is "active", regardless of <paramref name="Status" />.
/// Null when the API response carried no nested status object, which is the case for the attach (POST) and
/// update (PUT) responses.
/// </param>
[JsonConverter(typeof(CustomDomainResponseConverter))]
public record CustomDomainResponse(
string Domain,
string? EdgeHostname,
string Status
string Status,
string? SslStatus = null
);

/// <summary>
Expand Down Expand Up @@ -135,14 +149,17 @@ public override CustomDomainResponse Read(ref Utf8JsonReader reader, Type typeTo
string? edgeHostname = null;
// Default status, as a successful POST might not include it immediately.
var status = "pending_validation";
// The certificate status is only reported inside the nested status object; it stays null otherwise.
string? sslStatus = null;

while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
return new CustomDomainResponse(
domain ?? throw new JsonException("Missing required 'domain' property in CustomDomainResponse."),
edgeHostname, // edgeHostname can be missing in some API responses.
status
status,
sslStatus // Null unless the nested status object was present.
);

if (reader.TokenType == JsonTokenType.PropertyName)
Expand Down Expand Up @@ -170,6 +187,9 @@ public override CustomDomainResponse Read(ref Utf8JsonReader reader, Type typeTo
// We pass the existing options to the nested deserialization call.
var statusObj = JsonSerializer.Deserialize<CustomDomainStatusObject>(ref reader, options);
status = statusObj?.Ownership ?? "pending";
// The 'ssl' field is surfaced separately: a hostname that no wildcard certificate covers only
// serves traffic once the certificate status is "active", whatever the ownership status says.
sslStatus = statusObj?.Ssl;
}

break;
Expand Down Expand Up @@ -203,6 +223,10 @@ public override void Write(Utf8JsonWriter writer, CustomDomainResponse value, Js
// Write "status" property.
writer.WriteString(namingPolicy.ConvertName(nameof(value.Status)), value.Status);

// Write "ssl_status" property if it's not null.
if (value.SslStatus is not null)
writer.WriteString(namingPolicy.ConvertName(nameof(value.SslStatus)), value.SslStatus);

writer.WriteEndObject();
}

Expand Down
2 changes: 1 addition & 1 deletion src/Cloudflare.NET/Cloudflare.NET.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<!-- NuGet Package Properties (common properties inherited from Directory.Build.props) -->
<PackageId>Cloudflare.NET.Api</PackageId>
<Version>3.6.0</Version>
<Version>3.7.0</Version>
<Description>Cloudflare.NET - A comprehensive C# client library for the Cloudflare REST API. Manage DNS records, Zones, R2 buckets, Workers, WAF rules, Turnstile, and security features with strongly-typed .NET code.</Description>
<PackageTags>cloudflare;cloudflare-api;cloudflare-sdk;cloudflare-client;dotnet;csharp;dns;r2;waf;firewall;zone;workers;turnstile;api-client;rest-client</PackageTags>
</PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -796,16 +796,18 @@ await action.Should().ThrowAsync<HttpRequestException>()
.Where(ex => ex.StatusCode == HttpStatusCode.NotFound);
}

/// <summary>I23: Verifies that GetAccountTokenAsync with a malformed account ID returns HTTP 400.</summary>
/// <summary>I23: Verifies that GetAccountTokenAsync with a malformed account ID fails at the routing layer.</summary>
/// <remarks>
/// Malformed account IDs containing special characters that cannot be parsed as valid
/// identifiers return 400 BadRequest with error code 7003 "Could not route to..."
/// because the request fails at the routing/parsing layer.
/// identifiers fail with error code 7003 "Could not route to..." because the request
/// fails at the routing/parsing layer. Cloudflare returned this as 400 BadRequest until at
/// least 2026-08-27 and as 404 NotFound from 2026-09-20 (observed in CI); both statuses are
/// accepted so the test pins the routing failure rather than the transport status.
/// </remarks>
[IntegrationTest]
public async Task GetAccountTokenAsync_MalformedAccountId_ThrowsBadRequest()
public async Task GetAccountTokenAsync_MalformedAccountId_ThrowsRoutingError()
{
// Arrange - Use special characters that cause a parsing error (400 BadRequest)
// Arrange - Use special characters that cause a parsing error at the routing layer
var malformedAccountId = "!@#$%^&*()";
// Token ID format is valid (32 hex chars) - actual existence doesn't matter since
// account ID validation occurs first and will reject the malformed account ID
Expand All @@ -814,7 +816,7 @@ public async Task GetAccountTokenAsync_MalformedAccountId_ThrowsBadRequest()
// Act & Assert
var action = async () => await _sut.GetAccountTokenAsync(malformedAccountId, validFormatTokenId);
await action.Should().ThrowAsync<HttpRequestException>()
.Where(ex => ex.StatusCode == HttpStatusCode.BadRequest);
.Where(ex => ex.StatusCode == HttpStatusCode.BadRequest || ex.StatusCode == HttpStatusCode.NotFound);
}

/// <summary>I24: Verifies that GetAccountTokenAsync with a malformed token ID returns 400 Bad Request.</summary>
Expand All @@ -831,22 +833,24 @@ await action.Should().ThrowAsync<HttpRequestException>()
.Where(ex => ex.StatusCode == HttpStatusCode.BadRequest);
}

/// <summary>I25: Verifies that ListAccountTokensAsync with a malformed account ID returns HTTP 400.</summary>
/// <summary>I25: Verifies that ListAccountTokensAsync with a malformed account ID fails at the routing layer.</summary>
/// <remarks>
/// Malformed account IDs containing special characters that cannot be parsed as valid
/// identifiers return 400 BadRequest with error code 7003 "Could not route to..."
/// because the request fails at the routing/parsing layer.
/// identifiers fail with error code 7003 "Could not route to..." because the request
/// fails at the routing/parsing layer. Cloudflare returned this as 400 BadRequest until at
/// least 2026-08-27 and as 404 NotFound from 2026-09-20 (observed in CI); both statuses are
/// accepted so the test pins the routing failure rather than the transport status.
/// </remarks>
[IntegrationTest]
public async Task ListAccountTokensAsync_MalformedAccountId_ThrowsBadRequest()
public async Task ListAccountTokensAsync_MalformedAccountId_ThrowsRoutingError()
{
// Arrange - Use special characters that cause a parsing error (400 BadRequest)
// Arrange - Use special characters that cause a parsing error at the routing layer
var malformedAccountId = "!@#$%^&*()";

// Act & Assert
var action = async () => await _sut.ListAccountTokensAsync(malformedAccountId);
await action.Should().ThrowAsync<HttpRequestException>()
.Where(ex => ex.StatusCode == HttpStatusCode.BadRequest);
.Where(ex => ex.StatusCode == HttpStatusCode.BadRequest || ex.StatusCode == HttpStatusCode.NotFound);
}

#endregion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -441,11 +441,13 @@ await act.Should().ThrowAsync<HttpRequestException>()
.Where(ex => ex.StatusCode == HttpStatusCode.Forbidden);
}

/// <summary>I16: Verifies that a malformed account ID with special characters returns HTTP 400.</summary>
/// <summary>I16: Verifies that a malformed account ID with special characters fails at the routing layer.</summary>
/// <remarks>
/// Malformed account IDs containing special characters that are not valid in URL paths
/// return 400 BadRequest with error code 7003 "Could not route to..." because the
/// request cannot be parsed correctly at the routing layer.
/// fail with error code 7003 "Could not route to..." because the request cannot be parsed
/// correctly at the routing layer. Cloudflare returned this as 400 BadRequest until at least
/// 2026-08-27 and as 404 NotFound from 2026-09-20 (observed in CI); both statuses are accepted
/// so the test pins the routing failure rather than the transport status.
/// </remarks>
[IntegrationTest]
public async Task GetAccountAuditLogsAsync_MalformedAccountId_ReturnsError()
Expand All @@ -457,9 +459,9 @@ public async Task GetAccountAuditLogsAsync_MalformedAccountId_ReturnsError()
// Act
var act = () => _sut.GetAccountAuditLogsAsync(malformedAccountId, filters);

// Assert - Special characters return 400 BadRequest (routing/parsing error)
// Assert - Special characters fail at the routing layer (400 or 404 depending on the API's current behavior)
await act.Should().ThrowAsync<HttpRequestException>()
.Where(ex => ex.StatusCode == HttpStatusCode.BadRequest);
.Where(ex => ex.StatusCode == HttpStatusCode.BadRequest || ex.StatusCode == HttpStatusCode.NotFound);
}

#endregion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -490,8 +490,18 @@ public async Task ListKeysAsync_WithPrefix_FiltersCorrectly()

try
{
// Act
var result = await _sut.ListKeysAsync(_namespaceId, new ListKvKeysFilters(Prefix: prefix));
// Act. The KV list index is eventually consistent: a just-written key can be absent from the
// first list response (observed in CI on 2026-08-27), so poll until both keys appear or the
// deadline expires, mirroring the propagation polling in BulkDeleteAsync_CanDeleteMultipleKeys.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(120);
const int retryDelayMs = 5000;
var result = await _sut.ListKeysAsync(_namespaceId, new ListKvKeysFilters(Prefix: prefix));

while (result.Items.Count < 2 && DateTime.UtcNow < deadline)
{
await Task.Delay(retryDelayMs);
result = await _sut.ListKeysAsync(_namespaceId, new ListKvKeysFilters(Prefix: prefix));
}

// Assert
result.Items.Should().HaveCount(2, "only keys with the prefix should be returned");
Expand Down Expand Up @@ -521,10 +531,26 @@ public async Task ListAllKeysAsync_ShouldIterateThroughAllKeys()

try
{
// Act
var allKeys = new List<KvKey>();
await foreach (var key in _sut.ListAllKeysAsync(_namespaceId, prefix))
allKeys.Add(key);
// Act. The KV list index is eventually consistent: a just-written key can be absent from the
// first list response (observed in CI on 2026-08-27), so re-enumerate until all five keys
// appear or the deadline expires, mirroring the propagation polling in
// BulkDeleteAsync_CanDeleteMultipleKeys.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(120);
const int retryDelayMs = 5000;
var allKeys = new List<KvKey>();

while (true)
{
allKeys.Clear();

await foreach (var key in _sut.ListAllKeysAsync(_namespaceId, prefix))
allKeys.Add(key);

if (allKeys.Count >= 5 || DateTime.UtcNow >= deadline)
break;

await Task.Delay(retryDelayMs);
}

// Assert
allKeys.Should().HaveCount(5, "all keys with the prefix should be returned");
Expand All @@ -549,8 +575,19 @@ public async Task ListKeysAsync_IncludesKeyMetadata()

try
{
// Act
var result = await _sut.ListKeysAsync(_namespaceId, new ListKvKeysFilters(Prefix: key));
// Act. The KV list index is eventually consistent: a just-written key can be absent from the
// first list response (this exact assertion failed in CI on 2026-08-27 with an empty list),
// so poll until the key appears or the deadline expires, mirroring the propagation polling in
// BulkDeleteAsync_CanDeleteMultipleKeys.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(120);
const int retryDelayMs = 5000;
var result = await _sut.ListKeysAsync(_namespaceId, new ListKvKeysFilters(Prefix: key));

while (result.Items.Count == 0 && DateTime.UtcNow < deadline)
{
await Task.Delay(retryDelayMs);
result = await _sut.ListKeysAsync(_namespaceId, new ListKvKeysFilters(Prefix: key));
}

// Assert
result.Items.Should().ContainSingle();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ public async Task CanManageCustomDomainLifecycle()
statusResult.Should().NotBeNull();
statusResult.Status.Should()
.BeOneOf("pending", "active"); // Status depends on timing
// The GET response always carries the nested status object, so the certificate status is reported too.
// Its value depends on timing; the documented set is checked rather than one fixed value.
statusResult.SslStatus.Should()
.BeOneOf("initializing", "pending", "active", "deactivated", "error", "unknown");

// 3. Update Custom Domain (set minimum TLS version)
var updateRequest = new UpdateCustomDomainRequest(Enabled: true, MinTls: "1.2");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -337,26 +337,29 @@ await act.Should()
.Where(ex => ex.StatusCode == System.Net.HttpStatusCode.NotFound);
}

/// <summary>I15: Verifies that malformed zone ID returns 400 Bad Request.</summary>
/// <summary>I15: Verifies that a malformed zone ID fails at the routing layer.</summary>
/// <remarks>
/// Per Cloudflare API: Malformed zone IDs with invalid characters fail at the routing layer.
/// Error code 7003: "Could not route to /zones/{id}/subscription, perhaps your object identifier is invalid?"
/// Error code 7000: "No route for that URI"
/// https://developers.cloudflare.com/api/resources/zones/
/// Cloudflare returned this as 400 BadRequest until at least 2026-08-27 and as 404 NotFound from
/// 2026-09-20 (observed in CI); both statuses are accepted so the test pins the routing failure
/// rather than the transport status.
/// </remarks>
[IntegrationTest]
public async Task GetZoneSubscriptionAsync_MalformedId_ThrowsBadRequest()
public async Task GetZoneSubscriptionAsync_MalformedId_ThrowsRoutingError()
{
// Arrange
var malformedId = "!!!invalid-format!!!";

// Act
var act = () => _sut.GetZoneSubscriptionAsync(malformedId);

// Assert - Malformed zone ID returns 400 Bad Request (routing error)
// Assert - Malformed zone ID fails at the routing layer (400 or 404 depending on the API's current behavior)
await act.Should()
.ThrowAsync<HttpRequestException>()
.Where(ex => ex.StatusCode == System.Net.HttpStatusCode.BadRequest);
.Where(ex => ex.StatusCode == System.Net.HttpStatusCode.BadRequest || ex.StatusCode == System.Net.HttpStatusCode.NotFound);
}

#endregion
Expand Down
Loading
Loading