| File: AcrLoginServiceTests.cs | Web Access |
| Project: src\tests\Aspire.Hosting.Azure.Tests\Aspire.Hosting.Azure.Tests.csproj (Aspire.Hosting.Azure.Tests) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIRECONTAINERRUNTIME001 using System.Net; using Aspire.Hosting.Tests.Publishing; using Azure.Core; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Hosting.Azure.Tests; public class AcrLoginServiceTests { [Fact] public async Task LoginAsync_RetriesTransientExchangeFailures() { var handler = new CallbackHttpMessageHandler((attempt, _) => { if (attempt < 3) { throw new HttpRequestException("Name or service not known"); } return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"refresh_token":"refresh-token"}""") }); }); var runtime = new FakeContainerRuntime(); var timeProvider = new ImmediateTimeProvider(); var service = new AcrLoginService( new TestHttpClientFactory(handler), runtime, NullLogger<AcrLoginService>.Instance, timeProvider); await service.LoginAsync("registry.azurecr.io", "tenant", new StaticTokenCredential()); Assert.Equal(3, handler.CallCount); Assert.Equal(2, timeProvider.DelayCount); Assert.True(runtime.WasLoginToRegistryCalled); var login = Assert.Single(runtime.LoginToRegistryCalls); Assert.Equal("registry.azurecr.io", login.registryServer); Assert.Equal("refresh-token", login.password); } [Fact] public async Task LoginAsync_DoesNotRetryNonRetryableExchangeFailures() { var handler = new CallbackHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("bad request") })); var runtime = new FakeContainerRuntime(); var timeProvider = new ImmediateTimeProvider(); var service = new AcrLoginService( new TestHttpClientFactory(handler), runtime, NullLogger<AcrLoginService>.Instance, timeProvider); await Assert.ThrowsAsync<HttpRequestException>(() => service.LoginAsync("registry.azurecr.io", "tenant", new StaticTokenCredential())); Assert.Equal(1, handler.CallCount); Assert.Equal(0, timeProvider.DelayCount); Assert.False(runtime.WasLoginToRegistryCalled); } [Fact] public async Task LoginAsync_StopsRetryingAfterMaxAttempts() { var handler = new CallbackHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("registry not ready") })); var runtime = new FakeContainerRuntime(); var timeProvider = new ImmediateTimeProvider(); var service = new AcrLoginService( new TestHttpClientFactory(handler), runtime, NullLogger<AcrLoginService>.Instance, timeProvider); await Assert.ThrowsAsync<HttpRequestException>(() => service.LoginAsync("registry.azurecr.io", "tenant", new StaticTokenCredential())); Assert.Equal(30, handler.CallCount); Assert.Equal(29, timeProvider.DelayCount); Assert.False(runtime.WasLoginToRegistryCalled); } [Fact] public async Task LoginAsync_StopsRetryingAfterTimeBudgetExceeded() { var handler = new CallbackHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("registry not ready") })); var runtime = new FakeContainerRuntime(); // ElapsedTimeProvider simulates 2 minutes elapsed after the first GetTimestamp() call, // so the s_maxLoginRetryDuration (1 minute) guard in ShouldRetryAcrLoginFailure trips // immediately and the loop stops after a single attempt without entering Task.Delay. var timeProvider = new ElapsedTimeProvider(); var service = new AcrLoginService( new TestHttpClientFactory(handler), runtime, NullLogger<AcrLoginService>.Instance, timeProvider); await Assert.ThrowsAsync<HttpRequestException>(() => service.LoginAsync("registry.azurecr.io", "tenant", new StaticTokenCredential())); Assert.Equal(1, handler.CallCount); Assert.Equal(0, timeProvider.DelayCount); Assert.False(runtime.WasLoginToRegistryCalled); } private sealed class CallbackHttpMessageHandler(Func<int, CancellationToken, Task<HttpResponseMessage>> callback) : HttpMessageHandler { public int CallCount { get; private set; } protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { CallCount++; return callback(CallCount, cancellationToken); } } private sealed class TestHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); } private sealed class StaticTokenCredential : TokenCredential { public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) { return new AccessToken("aad-token", DateTimeOffset.MaxValue); } public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) { return ValueTask.FromResult(GetToken(requestContext, cancellationToken)); } } private sealed class ImmediateTimeProvider : TimeProvider { public int DelayCount { get; private set; } public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { DelayCount++; var timer = new ImmediateTimer(); ThreadPool.QueueUserWorkItem(_ => { if (!timer.IsDisposed) { callback(state); } }); return timer; } } /// <summary> /// A <see cref="TimeProvider"/> that reports 2 minutes of elapsed time after the first /// <see cref="GetTimestamp"/> call so the time-budget guard in /// <c>ShouldRetryAcrLoginFailure</c> fires immediately after one failed attempt. /// </summary> private sealed class ElapsedTimeProvider : TimeProvider { public int DelayCount { get; private set; } private int _getTimestampCallCount; public override long GetTimestamp() { var count = Interlocked.Increment(ref _getTimestampCallCount); // First call captures the retryStartTimestamp (0). // All subsequent calls return 2 minutes of ticks so GetElapsedTime() exceeds // s_maxLoginRetryDuration (1 minute) and the retry guard returns false. return count == 1 ? 0L : TimestampFrequency * 120; } public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { DelayCount++; var timer = new ImmediateTimer(); ThreadPool.QueueUserWorkItem(_ => { if (!timer.IsDisposed) { callback(state); } }); return timer; } } private sealed class ImmediateTimer : ITimer { public bool IsDisposed { get; private set; } public bool Change(TimeSpan dueTime, TimeSpan period) => true; public void Dispose() { IsDisposed = true; } public ValueTask DisposeAsync() { IsDisposed = true; return ValueTask.CompletedTask; } } }