using System.Net; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.DataAccess; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace AutopilotMonitor.Functions.Tests; /// /// Pins the shared 401/403 contract of the Graph-backed device validators: attempt 1 drops the /// tenant's cached token (it may predate the consent grant on this instance) and reports /// transient so the loop re-mints; attempt 2 and every other status leave the token alone. /// public class GraphAuthFailureTests { private const string TenantId = "11111111-1111-1111-1111-111111111111"; private static Mock TokenService() { var cache = new MemoryCache(new MemoryCacheOptions()); var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001", ["EntraId:ClientSecret"] = "secret", }).Build(); var registry = new EntraAppRegistry(configuration, NullLogger.Instance); var tenantConfig = new Mock( Mock.Of(), NullLogger.Instance, cache) { CallBase = false }; return new Mock( NullLogger.Instance, Mock.Of(), cache, registry, tenantConfig.Object) { CallBase = false }; } [Theory] [InlineData(HttpStatusCode.Unauthorized)] [InlineData(HttpStatusCode.Forbidden)] public void AuthFailure_OnFirstAttempt_InvalidatesOnce_AndAsksForRetry(HttpStatusCode status) { var tokens = TokenService(); var retry = GraphAuthFailure.TryRecoverStaleToken(tokens.Object, NullLogger.Instance, "Test", TenantId, status, attempt: 1); Assert.True(retry); tokens.Verify(t => t.InvalidateTenant(TenantId), Times.Once); } [Theory] [InlineData(HttpStatusCode.Unauthorized)] [InlineData(HttpStatusCode.Forbidden)] public void AuthFailure_OnSecondAttempt_LeavesTheFreshToken_AndDefersToTheValidator(HttpStatusCode status) { var tokens = TokenService(); var retry = GraphAuthFailure.TryRecoverStaleToken(tokens.Object, NullLogger.Instance, "Test", TenantId, status, attempt: 2); Assert.False(retry); tokens.Verify(t => t.InvalidateTenant(It.IsAny()), Times.Never); } [Theory] [InlineData(HttpStatusCode.NotFound)] [InlineData(HttpStatusCode.BadRequest)] [InlineData(HttpStatusCode.TooManyRequests)] [InlineData(HttpStatusCode.InternalServerError)] [InlineData(HttpStatusCode.GatewayTimeout)] public void OtherStatuses_NeverTouchTheToken(HttpStatusCode status) { var tokens = TokenService(); var retry = GraphAuthFailure.TryRecoverStaleToken(tokens.Object, NullLogger.Instance, "Test", TenantId, status, attempt: 1); Assert.False(retry); Assert.False(GraphAuthFailure.IsAuthFailure(status)); tokens.Verify(t => t.InvalidateTenant(It.IsAny()), Times.Never); } [Fact] public void PermissionMissingRetryAfter_IsLongerThanTheDefault_ButInsideTheAgentsPatience() { // 30 s is the SecurityValidator default for a transient failure; a consent gap needs more // than that to close, and the agent's replay logic honours any Retry-After. Assert.True(GraphAuthFailure.PermissionMissingRetryAfterSeconds > 30); Assert.True(GraphAuthFailure.PermissionMissingRetryAfterSeconds <= 300); } [Theory] [InlineData(null, null, null)] [InlineData(null, 120, 120)] [InlineData(30, null, 30)] [InlineData(30, 120, 120)] [InlineData(120, 30, 120)] public void SecurityValidator_KeepsTheLargestRetryAfter(int? accumulated, int? next, int? expected) => Assert.Equal(expected, SecurityValidator.MaxRetryAfter(accumulated, next)); }