diff --git a/src/Certify.ACME.Anvil/Acme/EntityContext.cs b/src/Certify.ACME.Anvil/Acme/EntityContext.cs index d51aa707..e84e013c 100644 --- a/src/Certify.ACME.Anvil/Acme/EntityContext.cs +++ b/src/Certify.ACME.Anvil/Acme/EntityContext.cs @@ -30,6 +30,10 @@ internal class EntityContext /// public int RetryAfter { get; protected set; } + /// + /// Gets the next retry time if available from the server. + /// + public DateTime? NextRetry { get; protected set; } /// /// Initializes a new instance of the class. @@ -51,6 +55,7 @@ public EntityContext( public virtual async Task Resource() { var resp = await Context.HttpClient.Post(Context, Location, null, true); + NextRetry = (resp.RetryAfter <= 0) ? null : DateTime.UtcNow.AddSeconds(resp.RetryAfter); return resp.Resource; } } diff --git a/src/Certify.ACME.Anvil/Acme/IOrderContext.cs b/src/Certify.ACME.Anvil/Acme/IOrderContext.cs index be37c1a3..c790b927 100644 --- a/src/Certify.ACME.Anvil/Acme/IOrderContext.cs +++ b/src/Certify.ACME.Anvil/Acme/IOrderContext.cs @@ -30,5 +30,13 @@ public interface IOrderContext : IResourceContext /// The preferred Root Certificate. /// The certificate chain in PEM. Task Download(string preferredChain = null); + + /// + /// Downloads the certificate chain in PEM for the specified order. + /// + /// The finalized order returned by or . + /// The preferred Root Certificate. + /// The certificate chain in PEM. + Task Download(Order order, string preferredChain = null); } } diff --git a/src/Certify.ACME.Anvil/Acme/IResourceContext.cs b/src/Certify.ACME.Anvil/Acme/IResourceContext.cs index 3ed061a5..0fee5699 100644 --- a/src/Certify.ACME.Anvil/Acme/IResourceContext.cs +++ b/src/Certify.ACME.Anvil/Acme/IResourceContext.cs @@ -22,6 +22,11 @@ public interface IResourceContext /// int RetryAfter { get; } + /// + /// Gets the next retry time if available from the server. + /// + DateTime? NextRetry { get; } + /// /// Gets the ACME resource. /// diff --git a/src/Certify.ACME.Anvil/Acme/OrderContext.cs b/src/Certify.ACME.Anvil/Acme/OrderContext.cs index bc5fea10..0661c702 100644 --- a/src/Certify.ACME.Anvil/Acme/OrderContext.cs +++ b/src/Certify.ACME.Anvil/Acme/OrderContext.cs @@ -47,6 +47,7 @@ public async Task Finalize(byte[] csr) var order = await Resource(); var payload = new Order.Payload { Csr = JwsConvert.ToBase64String(csr) }; var resp = await Context.HttpClient.Post(Context, order.Finalize, payload, true); + NextRetry = (resp.RetryAfter <= 0) ? null : DateTime.UtcNow.AddSeconds(resp.RetryAfter); return resp.Resource; } @@ -57,19 +58,18 @@ public async Task Finalize(byte[] csr) /// The certificate chain in PEM. public async Task Download(string preferredChain = null) { - var order = await Resource(); - - var retryCount = 5; + var order = await IOrderContextExtensions.WaitForCompletionAsync(this, TimeSpan.FromSeconds(15)); - while (order?.Certificate == null && retryCount > 0) - { - await Task.Delay(TimeSpan.FromSeconds(Math.Max(RetryAfter, 3))); - order = await Resource(); + return await Download(order, preferredChain); + } - retryCount--; - } + /// + public async Task Download(Order order, string preferredChain = null) + { + if (order == null) + throw new ArgumentNullException(nameof(order)); - if (order?.Certificate == null) + if (order.Certificate == null) { throw new AcmeException($"The order [status: {order.Status}] does not have a certificate URL. The CA has failed to complete the ACME Order process."); } diff --git a/src/Certify.ACME.Anvil/Extensions/IAuthorizationContextExtensions.cs b/src/Certify.ACME.Anvil/Extensions/IAuthorizationContextExtensions.cs index d73cc133..b81d3eb6 100644 --- a/src/Certify.ACME.Anvil/Extensions/IAuthorizationContextExtensions.cs +++ b/src/Certify.ACME.Anvil/Extensions/IAuthorizationContextExtensions.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using Certify.ACME.Anvil.Acme; using Certify.ACME.Anvil.Acme.Resource; @@ -45,5 +47,31 @@ public static async Task Challenge(this IAuthorizationContext var challenges = await authorizationContext.Challenges(); return challenges.FirstOrDefault(c => c.Type == type); } + + /// + /// Waits for the authorization to reach a terminal state (valid). + /// + /// The authorization context. + /// The maximum time to wait for completion. Use TimeSpan.Zero to check once without retrying. + /// Cancellation token to allow external cancellation of the polling operation. + /// The authorization validated. + /// Thrown when the authorization status is not Valid after completion. + /// Thrown when timeout is negative. + /// Thrown when the operation is cancelled. + public static Task WaitForCompletionAsync(this IAuthorizationContext context, TimeSpan timeout, CancellationToken cancellationToken = default) + { + return ResourceContextPollingHelper.WaitForCompletionAsync( + context, + timeout, + cancellationToken, + isTerminalState: authorization => authorization?.Status != AuthorizationStatus.Pending, + validateFinalState: authorization => + { + if (authorization.Status != AuthorizationStatus.Valid) + { + throw new AcmeException(Properties.Strings.ErrorChallengeValidationFailed); + } + }); + } } } diff --git a/src/Certify.ACME.Anvil/Extensions/IOrderContextExtensions.cs b/src/Certify.ACME.Anvil/Extensions/IOrderContextExtensions.cs index 830a1c88..c613005f 100644 --- a/src/Certify.ACME.Anvil/Extensions/IOrderContextExtensions.cs +++ b/src/Certify.ACME.Anvil/Extensions/IOrderContextExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; using Certify.ACME.Anvil.Acme; using Certify.ACME.Anvil.Acme.Resource; @@ -59,18 +60,62 @@ public static async Task CreateCsr(this IOrderConte return builder; } + /// + /// Waits for the order to reach a terminal state (valid). + /// + /// The order context. + /// The maximum time to wait for completion. Use TimeSpan.Zero to check once without retrying. + /// Cancellation token to allow external cancellation of the polling operation. + /// The order validated. + /// Thrown when the order status is not Valid after completion. + /// Thrown when timeout is negative. + /// Thrown when the operation is cancelled. + public static Task WaitForCompletionAsync(this IOrderContext context, TimeSpan timeout, CancellationToken cancellationToken = default) + { + return ResourceContextPollingHelper.WaitForCompletionAsync( + context, + timeout, + cancellationToken, + isTerminalState: order => (order?.Status == OrderStatus.Valid && order?.Certificate != null) || order?.Status == OrderStatus.Invalid, + validateFinalState: order => + { + if (order.Status != OrderStatus.Valid) + throw new AcmeException(Strings.ErrorFinalizeFailed); + }); + } + /// /// Finalizes and download the certificate for the order. /// /// The order context. /// The CSR. /// The private key for the certificate. - /// Number of retries when the Order is in 'processing' state. (default = 1) /// The preferred Root Certificate. + /// Number of retries when the Order is in 'processing' state. (default = 3) /// /// The certificate generated. /// - public static async Task Generate(this IOrderContext context, CsrInfo csr, IKey key, string preferredChain = null, int retryCount = 3) + [Obsolete("Use Generate(context, csr, key, preferredChain, timeout, cancellationToken) instead.")] + public static Task Generate(this IOrderContext context, CsrInfo csr, IKey key, string preferredChain = null, int retryCount = 3) + { + // Estimate total timeout based on inputs. Actual retries may be fewer if the order reaches a terminal state sooner. + var estimatedTimeout = TimeSpan.FromSeconds(Math.Max(context.RetryAfter, 2) * retryCount); + return Generate(context, csr, key, preferredChain, estimatedTimeout, CancellationToken.None); + } + + /// + /// Finalizes and download the certificate for the order. + /// + /// The order context. + /// The CSR. + /// The private key for the certificate. + /// The preferred Root Certificate. + /// Maximum time to wait for the Order to reach 'valid' state. + /// Cancellation token to allow external cancellation of the polling operation. + /// + /// The certificate generated. + /// + public static async Task Generate(this IOrderContext context, CsrInfo csr, IKey key, string preferredChain, TimeSpan timeout, CancellationToken cancellationToken = default) { var order = await context.Resource(); if (order.Status != OrderStatus.Ready && // draft-11 @@ -81,18 +126,12 @@ public static async Task Generate(this IOrderContext context, order = await context.Finalize(csr, key); - while ((order?.Status == OrderStatus.Processing || order.Certificate == null) && retryCount-- > 0) - { - await Task.Delay(TimeSpan.FromSeconds(Math.Max(context.RetryAfter, 2))); - order = await context.Resource(); - } - if (order.Status != OrderStatus.Valid) { - throw new AcmeException(Strings.ErrorFinalizeFailed); + order = await context.WaitForCompletionAsync(timeout, cancellationToken); } - return await context.Download(preferredChain); + return await context.Download(order, preferredChain); } /// diff --git a/src/Certify.ACME.Anvil/Extensions/ResourceContextPollingHelper.cs b/src/Certify.ACME.Anvil/Extensions/ResourceContextPollingHelper.cs new file mode 100644 index 00000000..ae0d9f11 --- /dev/null +++ b/src/Certify.ACME.Anvil/Extensions/ResourceContextPollingHelper.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Certify.ACME.Anvil.Acme; + +namespace Certify.ACME.Anvil +{ + /// + /// Internal helper for polling resource contexts until reaching a terminal state. + /// + internal static class ResourceContextPollingHelper + { + /// + /// Waits for a resource to reach a terminal state with timeout-based polling. + /// + /// The resource type. + /// The resource context. + /// The maximum time to wait for completion. Use TimeSpan.Zero to check once without retrying. + /// Cancellation token to allow external cancellation of the polling operation. + /// Function to determine if the resource is in a terminal state. + /// Function to validate the final state and throw if invalid. + /// The resource in terminal state. + /// Thrown when timeout is negative. + /// Thrown when the operation is cancelled via the cancellation token. + public static async Task WaitForCompletionAsync(IResourceContext context, TimeSpan timeout, CancellationToken cancellationToken, Func isTerminalState, Action validateFinalState) + { + if (timeout < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(timeout), Properties.Strings.ErrorRetryLessThanZero); + } + + var resource = await context.Resource(); + var startTime = DateTime.UtcNow; + var endTime = startTime.Add(timeout); + + while (!isTerminalState(resource) && DateTime.UtcNow < endTime) + { + // Check cancellation before starting new work + cancellationToken.ThrowIfCancellationRequested(); + + // Determine how long to wait + TimeSpan delayDuration; + + if (context.NextRetry.HasValue && context.NextRetry > DateTime.UtcNow) + { + // Use NextRetry if available and in the future + delayDuration = context.NextRetry.Value - DateTime.UtcNow; + } + else + { + // Fall back to RetryAfter + delayDuration = TimeSpan.FromSeconds(context.RetryAfter > 0 ? context.RetryAfter : 3); + } + + // Ensure we don't wait past the timeout + var remainingTime = endTime - DateTime.UtcNow; + if (delayDuration > remainingTime) + { + delayDuration = remainingTime; + } + + if (delayDuration > TimeSpan.Zero) + { + // Cancellation during delay will throw OperationCanceledException + await Task.Delay(delayDuration, cancellationToken); + } + + // Fetch the resource status + resource = await context.Resource(); + } + + // If we're here, we either succeeded or timed out (cancellation would have thrown) + validateFinalState(resource); + return resource; + } + } +} diff --git a/src/Certify.ACME.Anvil/Properties/Strings.Designer.cs b/src/Certify.ACME.Anvil/Properties/Strings.Designer.cs index 222c97a8..c13591b3 100644 --- a/src/Certify.ACME.Anvil/Properties/Strings.Designer.cs +++ b/src/Certify.ACME.Anvil/Properties/Strings.Designer.cs @@ -60,6 +60,15 @@ internal Strings() { } } + /// + /// Looks up a localized string similar to Challenge validation failed or timed out.. + /// + internal static string ErrorChallengeValidationFailed { + get { + return ResourceManager.GetString("ErrorChallengeValidationFailed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Fail to fetch new nonce.. /// @@ -132,6 +141,15 @@ internal static string ErrorMissingCertificateData { } } + /// + /// Looks up a localized string similar to Retry cannot be less than zero. + /// + internal static string ErrorRetryLessThanZero { + get { + return ResourceManager.GetString("ErrorRetryLessThanZero", resourceCulture); + } + } + /// /// Looks up a localized string similar to Unsupported resource type '{0}'.. /// diff --git a/src/Certify.ACME.Anvil/Properties/Strings.resx b/src/Certify.ACME.Anvil/Properties/Strings.resx index 239bbbe6..8edb9439 100644 --- a/src/Certify.ACME.Anvil/Properties/Strings.resx +++ b/src/Certify.ACME.Anvil/Properties/Strings.resx @@ -126,6 +126,9 @@ Fail to finalize order. + + Challenge validation failed or timed out. + Illegal base64url string. @@ -144,4 +147,7 @@ Unsupported resource type '{0}'. + + Retry cannot be less than zero + \ No newline at end of file diff --git a/test/Tests/Acme/OrderContextTests.cs b/test/Tests/Acme/OrderContextTests.cs index c806cf0e..c7755a1f 100644 --- a/test/Tests/Acme/OrderContextTests.cs +++ b/test/Tests/Acme/OrderContextTests.cs @@ -11,27 +11,15 @@ namespace Certify.ACME.Anvil.Acme { public class OrderContextTests { - private Uri location = new Uri("http://acme.d/order/101"); + private static readonly Uri location = new Uri("http://acme.d/order/101"); private Mock contextMock = new Mock(MockBehavior.Strict); - private Mock httpClientMock = new Mock(MockBehavior.Strict); + private readonly JwsPayload expectedPayload = new JwsSigner(Helper.GetKeyV2()).Sign("", null, location, "nonce"); - [Fact] - public async Task CanLoadAuthorizations() + private Mock GetHttpClientMock() { - var order = new Order - { - Authorizations = new[] - { - new Uri("http://acme.d/acct/1/authz/1"), - new Uri("http://acme.d/acct/1/authz/2"), - } - }; - - var expectedPayload = new JwsSigner(Helper.GetKeyV2()) - .Sign("", null, location, "nonce"); - contextMock.Reset(); - httpClientMock.Reset(); + + var httpClientMock = new Mock(MockBehavior.Strict); contextMock .Setup(c => c.GetDirectory(false)) @@ -44,13 +32,69 @@ public async Task CanLoadAuthorizations() .Returns(1); contextMock.SetupGet(c => c.HttpClient).Returns(httpClientMock.Object); contextMock - .Setup(c => c.Sign(It.IsAny(), It.IsAny())) + .Setup(c => c.Sign(It.IsAny(), location)) .Callback((object payload, Uri loc) => { Assert.Null(payload); Assert.Equal(location, loc); }) .ReturnsAsync(expectedPayload); + + return httpClientMock; + } + + private Mock GetHttpClientMockForFinalize(int retryAfter) + { + var finalizeUri = new Uri("http://acme.d/order/101/finalize"); + var order = new Order + { + Status = OrderStatus.Ready, + Finalize = finalizeUri + }; + var finalizedOrder = new Order + { + Status = OrderStatus.Processing, + Finalize = finalizeUri + }; + + var httpClientMock = GetHttpClientMock(); + + // Mock the initial Resource() call + httpClientMock + .Setup(m => m.Post(location, It.IsAny())) + .ReturnsAsync(new AcmeHttpResponse(location, order, default, default)); + + // Mock the Finalize POST call with specified retryAfter + contextMock + .Setup(c => c.Sign(It.IsAny(), finalizeUri)) + .Callback((object payload, Uri loc) => + { + var orderPayload = Assert.IsType(payload); + Assert.NotNull(orderPayload.Csr); + }) + .ReturnsAsync(expectedPayload); + + httpClientMock + .Setup(m => m.Post(finalizeUri, It.IsAny())) + .ReturnsAsync(new AcmeHttpResponse(finalizeUri, finalizedOrder, default, default, retryAfter: retryAfter)); + + return httpClientMock; + } + + [Fact] + public async Task CanLoadAuthorizations() + { + var order = new Order + { + Authorizations = new[] + { + new Uri("http://acme.d/acct/1/authz/1"), + new Uri("http://acme.d/acct/1/authz/2"), + } + }; + + var httpClientMock = GetHttpClientMock(); + httpClientMock .Setup(m => m.Post(location, It.IsAny())) .Callback((Uri _, object o) => @@ -73,5 +117,105 @@ public async Task CanLoadAuthorizations() Assert.Empty(authzs); } + + [Fact] + public async Task Download_WithNullOrder_Throws() + { + contextMock.Reset(); + + var ctx = new OrderContext(contextMock.Object, location); + + await Assert.ThrowsAsync(async () => await ctx.Download(order: null, preferredChain: null)); + } + + [Fact] + public async Task Download_WithIncompleteOrder_Throws() + { + contextMock.Reset(); + + var ctx = new OrderContext(contextMock.Object, location); + + await Assert.ThrowsAsync(async () => await ctx.Download(order: IOrderContextExtensionsTests.BuildOrder(OrderStatus.Processing), preferredChain: null)); + } + + [Fact] + public async Task Resource_WithRetryAfterInPast_SetsNextRetryToNull() + { + var order = new Order + { + Status = OrderStatus.Pending + }; + + // Mock with retryAfter in the past (negative value indicates past) + GetHttpClientMock() + .Setup(m => m.Post(location, It.IsAny())) + .ReturnsAsync(new AcmeHttpResponse(location, order, default, default, retryAfter: -10)); + + var ctx = new OrderContext(contextMock.Object, location); + var result = await ctx.Resource(); + + Assert.Equal(order, result); + Assert.Null(ctx.NextRetry); // Should be null when retryAfter is in the past + } + + [Fact] + public async Task Resource_WithRetryAfterInFuture_SetsNextRetry() + { + var order = new Order + { + Status = OrderStatus.Processing + }; + + // Mock with retryAfter in the future (positive value in seconds) + var retryAfterSeconds = 30; + GetHttpClientMock() + .Setup(m => m.Post(location, It.IsAny())) + .ReturnsAsync(new AcmeHttpResponse(location, order, default, default, retryAfter: retryAfterSeconds)); + + var beforeCall = DateTime.UtcNow; + var ctx = new OrderContext(contextMock.Object, location); + var result = await ctx.Resource(); + var afterCall = DateTime.UtcNow; + + Assert.Equal(order, result); + Assert.NotNull(ctx.NextRetry); + + // Verify NextRetry is set to approximately retryAfterSeconds in the future + var expectedRetry = beforeCall.AddSeconds(retryAfterSeconds); + Assert.True(ctx.NextRetry.Value >= expectedRetry && ctx.NextRetry.Value <= afterCall.AddSeconds(retryAfterSeconds)); + } + + [Fact] + public async Task Finalize_WithRetryAfterInPast_SetsNextRetryToNull() + { + var csr = new byte[] { 0x01, 0x02, 0x03 }; + GetHttpClientMockForFinalize(retryAfter: -10); + + var ctx = new OrderContext(contextMock.Object, location); + var result = await ctx.Finalize(csr); + + Assert.NotNull(result); + Assert.Null(ctx.NextRetry); // Should be null when retryAfter is in the past + } + + [Fact] + public async Task Finalize_WithRetryAfterInFuture_SetsNextRetry() + { + var csr = new byte[] { 0x01, 0x02, 0x03 }; + var retryAfterSeconds = 30; + GetHttpClientMockForFinalize(retryAfter: retryAfterSeconds); + + var beforeCall = DateTime.UtcNow; + var ctx = new OrderContext(contextMock.Object, location); + var result = await ctx.Finalize(csr); + var afterCall = DateTime.UtcNow; + + Assert.NotNull(result); + Assert.NotNull(ctx.NextRetry); + + // Verify NextRetry is set to approximately retryAfterSeconds in the future + var expectedRetry = beforeCall.AddSeconds(retryAfterSeconds); + Assert.True(ctx.NextRetry.Value >= expectedRetry && ctx.NextRetry.Value <= afterCall.AddSeconds(retryAfterSeconds)); + } } } diff --git a/test/Tests/IAuthorizationContextExtensionsTests.cs b/test/Tests/IAuthorizationContextExtensionsTests.cs index 2883594f..20ceef07 100644 --- a/test/Tests/IAuthorizationContextExtensionsTests.cs +++ b/test/Tests/IAuthorizationContextExtensionsTests.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using Certify.ACME.Anvil.Acme; using Certify.ACME.Anvil.Acme.Resource; using Moq; @@ -28,5 +29,12 @@ public async Task CanGetTlsAlpnChallenge() Assert.Equal(challengeMock.Object, await ctxMock.Object.TlsAlpn()); } + + [Fact] + public async Task WaitForCompletionAsync_WithNegativeRetry_Throws() + { + var ctxMock = new Mock(); + await Assert.ThrowsAsync(() => ctxMock.Object.WaitForCompletionAsync(TimeSpan.FromSeconds(-1))); + } } } diff --git a/test/Tests/IOrderContextExtensionsTests.cs b/test/Tests/IOrderContextExtensionsTests.cs index 0b5d92fd..71f37bb1 100644 --- a/test/Tests/IOrderContextExtensionsTests.cs +++ b/test/Tests/IOrderContextExtensionsTests.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -10,27 +11,37 @@ namespace Certify.ACME.Anvil { public class IOrderContextExtensionsTests { + + internal static Order BuildOrder(OrderStatus status) + { + var order = new Order + { + Identifiers = new[] { + new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, + }, + Status = status, + }; + + if (status == OrderStatus.Valid) + order.Certificate = new Uri("http://acme.d/order/101/cert/1234"); + + return order; + } + [Fact] public async Task CanGenerateCertificateWhenOrderReady() { var pem = File.ReadAllText("./Data/cert-es256.pem"); + var currentStatus = BuildOrder(OrderStatus.Ready); var orderCtxMock = new Mock(); - orderCtxMock.Setup(m => m.Download(null)).ReturnsAsync(new CertificateChain(pem)); - orderCtxMock.Setup(m => m.Resource()).ReturnsAsync(new Order - { - Identifiers = new[] { - new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, - }, - Status = OrderStatus.Ready, - }); + orderCtxMock.Setup(m => m.Download(It.IsAny(), null)).ReturnsAsync(new CertificateChain(pem)); + orderCtxMock.Setup(m => m.Resource()).ReturnsAsync(() => currentStatus); orderCtxMock.Setup(m => m.Finalize(It.IsAny())) - .ReturnsAsync(new Order + .ReturnsAsync(() => { - Identifiers = new[] { - new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, - }, - Status = OrderStatus.Valid, + currentStatus = BuildOrder(OrderStatus.Valid); + return currentStatus; }); var key = KeyFactory.NewKey(KeyAlgorithm.RS256); @@ -44,6 +55,8 @@ public async Task CanGenerateCertificateWhenOrderReady() pem.Where(c => !char.IsWhiteSpace(c)), certInfo.Certificate.ToPem().Where(c => !char.IsWhiteSpace(c))); + // Reset status to ready to test generating a certificate without a common name + currentStatus = BuildOrder(OrderStatus.Ready); var certInfoNoCn = await orderCtxMock.Object.Generate(new CsrInfo { CountryName = "C", @@ -59,22 +72,15 @@ public async Task CanGenerateCertificateWhenOrderPending() { var pem = File.ReadAllText("./Data/cert-es256.pem"); + var currentStatus = BuildOrder(OrderStatus.Pending); var orderCtxMock = new Mock(); - orderCtxMock.Setup(m => m.Download(null)).ReturnsAsync(new CertificateChain(pem)); - orderCtxMock.Setup(m => m.Resource()).ReturnsAsync(new Order - { - Identifiers = new[] { - new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, - }, - Status = OrderStatus.Pending, - }); + orderCtxMock.Setup(m => m.Download(It.IsAny(), null)).ReturnsAsync(new CertificateChain(pem)); + orderCtxMock.Setup(m => m.Resource()).ReturnsAsync(() => currentStatus); orderCtxMock.Setup(m => m.Finalize(It.IsAny())) - .ReturnsAsync(new Order + .ReturnsAsync(() => { - Identifiers = new[] { - new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, - }, - Status = OrderStatus.Valid, + currentStatus = BuildOrder(OrderStatus.Valid); + return currentStatus; }); var key = KeyFactory.NewKey(KeyAlgorithm.RS256); @@ -88,6 +94,8 @@ public async Task CanGenerateCertificateWhenOrderPending() pem.Where(c => !char.IsWhiteSpace(c)), certInfo.Certificate.ToPem().Where(c => !char.IsWhiteSpace(c))); + // Reset status to ready to test generating a certificate without a common name + currentStatus = BuildOrder(OrderStatus.Ready); var certInfoNoCn = await orderCtxMock.Object.Generate(new CsrInfo { CountryName = "C", @@ -104,7 +112,7 @@ public async Task CanGenerateCertificateWhenOrderProcessing() var pem = File.ReadAllText("./Data/cert-es256.pem"); var orderCtxMock = new Mock(); - orderCtxMock.Setup(m => m.Download(null)).ReturnsAsync(new CertificateChain(pem)); + orderCtxMock.Setup(m => m.Download(It.IsAny(), null)).ReturnsAsync(new CertificateChain(pem)); orderCtxMock.SetupSequence(m => m.Resource()) .ReturnsAsync(new Order { @@ -127,13 +135,7 @@ public async Task CanGenerateCertificateWhenOrderProcessing() }, Status = OrderStatus.Processing, }) - .ReturnsAsync(new Order - { - Identifiers = new[] { - new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, - }, - Status = OrderStatus.Valid, - }) + .ReturnsAsync(BuildOrder(OrderStatus.Valid)) .ReturnsAsync(new Order { Identifiers = new[] { @@ -148,13 +150,7 @@ public async Task CanGenerateCertificateWhenOrderProcessing() }, Status = OrderStatus.Ready, }) - .ReturnsAsync(new Order - { - Identifiers = new[] { - new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, - }, - Status = OrderStatus.Valid, - }); + .ReturnsAsync(BuildOrder(OrderStatus.Valid)); orderCtxMock.Setup(m => m.Finalize(It.IsAny())) .ReturnsAsync(new Order { @@ -169,7 +165,7 @@ public async Task CanGenerateCertificateWhenOrderProcessing() { CountryName = "C", CommonName = "www.example.com", - }, key, null, 5); + }, key, null, TimeSpan.FromSeconds(30)); Assert.Equal( pem.Where(c => !char.IsWhiteSpace(c)), @@ -229,6 +225,7 @@ public async Task CanGenerateWithAlternateLink() Identifiers = new[] { new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, }, + Certificate = certDefaultLoc, Status = OrderStatus.Valid, }, null, @@ -245,7 +242,6 @@ public async Task CanGenerateWithAlternateLink() Identifiers = new[] { new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, }, - Certificate = certDefaultLoc, Finalize = finalizeLoc, Status = OrderStatus.Pending, }); @@ -342,45 +338,16 @@ public async Task ThrowWhenProcessintTooOften() { var pem = File.ReadAllText("./Data/cert-es256.pem"); + var currentStatus = BuildOrder(OrderStatus.Ready); var orderCtxMock = new Mock(); orderCtxMock.Setup(m => m.Download(null)).ReturnsAsync(new CertificateChain(pem)); - orderCtxMock.SetupSequence(m => m.Resource()) - .ReturnsAsync(new Order - { - Identifiers = new[] { - new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, - }, - Status = OrderStatus.Ready, - }) - .ReturnsAsync(new Order - { - Identifiers = new[] { - new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, - }, - Status = OrderStatus.Ready, - }) - .ReturnsAsync(new Order - { - Identifiers = new[] { - new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, - }, - Status = OrderStatus.Processing, - }) - .ReturnsAsync(new Order - { - Identifiers = new[] { - new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, - }, - Status = OrderStatus.Processing, - }); + orderCtxMock.Setup(m => m.Resource()).ReturnsAsync(() => currentStatus); orderCtxMock.Setup(m => m.Finalize(It.IsAny())) - .ReturnsAsync(new Order + .ReturnsAsync(() => { - Identifiers = new[] { - new Identifier { Value = "www.example.com", Type = IdentifierType.Dns }, - }, - Status = OrderStatus.Processing, + currentStatus = BuildOrder(OrderStatus.Processing); + return currentStatus; }); var key = KeyFactory.NewKey(KeyAlgorithm.RS256); @@ -392,6 +359,54 @@ await Assert.ThrowsAsync(() => }, key)); } - } + [Fact] + public async Task WaitForCompletionAsync_WithNegativeRetry_Throws() + { + var orderCtxMock = new Mock(); + await Assert.ThrowsAsync(() => orderCtxMock.Object.WaitForCompletionAsync(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public async Task WaitForCompletionAsync_WithZeroTimeout_Success() + { + var orderCtxMock = new Mock(); + orderCtxMock.Setup(m => m.Resource()).ReturnsAsync(BuildOrder(OrderStatus.Valid)); + + var result = await orderCtxMock.Object.WaitForCompletionAsync(TimeSpan.Zero); + + Assert.NotNull(result); + Assert.Equal(OrderStatus.Valid, result.Status); + Assert.NotNull(result.Certificate); + orderCtxMock.Verify(m => m.Resource(), Times.Once()); + } + + [Fact] + public async Task WaitForCompletionAsync_ServerRetryAfter_RespectsTimeout() + { + var orderCtxMock = new Mock(); + + orderCtxMock.SetupSequence(m => m.Resource()) + .ReturnsAsync(BuildOrder(OrderStatus.Processing)) + .ReturnsAsync(BuildOrder(OrderStatus.Valid)); + orderCtxMock.SetupGet(m => m.RetryAfter).Returns(1); // This will be ignored + orderCtxMock.SetupGet(m => m.NextRetry).Returns(() => DateTime.UtcNow.AddSeconds(5)); // Mock NextRetry to return a time 5 seconds in the future + var startTime = DateTime.UtcNow; + + // Act: Call with 3 second timeout + var result = await orderCtxMock.Object.WaitForCompletionAsync(TimeSpan.FromSeconds(3)); + + var elapsed = DateTime.UtcNow - startTime; + + // Assert: the result is valid and Resource was called exactly twice (initial + 1 retry) + Assert.NotNull(result); + Assert.Equal(OrderStatus.Valid, result.Status); + orderCtxMock.Verify(m => m.Resource(), Times.Exactly(2)); + + // Verify the elapsed time is approximately 3 seconds (not 5) + // Allow for some variance due to test execution overhead + Assert.True(elapsed.TotalSeconds >= 2.5 && elapsed.TotalSeconds <= 4, + $"Expected elapsed time to be ~3 seconds, but was {elapsed.TotalSeconds:F2} seconds"); + } + } }