using AutopilotMonitor.Functions.Functions.Infrastructure; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Moq; using System.Net; using System.Security.Claims; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.DependencyInjection; namespace AutopilotMonitor.Functions.Tests; /// /// Tests for the side-effect methods extracted from AuthFunction.GetCurrentUser(). /// Uses Moq to verify service interactions (domain persistence, auto-re-enable, auto-admin, metrics). /// public class AuthFunctionSideEffectTests { private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; private const string Upn = "user@contoso.com"; private const string DisplayName = "Test User"; private const string ObjectId = "oid-12345"; private readonly Mock _tenantConfigMock; private readonly Mock _tenantAdminsMock; private readonly Mock _telegramMock; private readonly Mock _globalNotificationMock; private readonly Mock _metricsRepoMock; private readonly Mock _autoApproveEnqueuerMock; private readonly FakeSignalRNotificationService _signalR; private readonly AuthFunction _sut; public AuthFunctionSideEffectTests() { // Shared interface mocks for constructor injection var adminRepo = Mock.Of(); var configRepo = Mock.Of(); var notificationRepo = Mock.Of(); var cache = Mock.Of(); _tenantConfigMock = new Mock( configRepo, Mock.Of>(), cache) { CallBase = false }; var bindings = new StubAdminIdentityBindingService(bound: true); var globalAdminMock = new Mock( adminRepo, bindings, cache, Mock.Of>()) { CallBase = false }; var delegatedAdminMock = new Mock( adminRepo, bindings, new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro), cache, Mock.Of>()) { CallBase = false }; delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny())) .ReturnsAsync(DelegatedScope.Empty); _tenantAdminsMock = new Mock( adminRepo, cache, Mock.Of>()) { CallBase = false }; var previewMock = new Mock( configRepo, cache, Mock.Of>(), _tenantConfigMock.Object) { CallBase = false }; _telegramMock = new Mock( new HttpClient(), configRepo, Mock.Of>()) { CallBase = false }; _globalNotificationMock = new Mock( notificationRepo, new FakeSignalRNotificationService(), Mock.Of>()) { CallBase = false }; var adminConfigService = new Mock( configRepo, Mock.Of>(), cache) { CallBase = false }; var mcpUserMock = new Mock( adminRepo, new StubAdminIdentityBindingService(bound: true), cache, Mock.Of>(), globalAdminMock.Object, delegatedAdminMock.Object, adminConfigService.Object) { CallBase = false }; _metricsRepoMock = new Mock(); _autoApproveEnqueuerMock = new Mock(); _signalR = new FakeSignalRNotificationService(); _sut = new AuthFunction( Mock.Of>(), globalAdminMock.Object, delegatedAdminMock.Object, _tenantConfigMock.Object, _tenantAdminsMock.Object, _metricsRepoMock.Object, previewMock.Object, _telegramMock.Object, _globalNotificationMock.Object, mcpUserMock.Object, _autoApproveEnqueuerMock.Object, new AutopilotMonitor.Functions.Security.EntraAppRegistry( new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), Mock.Of>()), new Mock( _metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of>()) { CallBase = false }.Object, _signalR); // Default: all fire-and-forget calls succeed _tenantConfigMock .Setup(x => x.SaveConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); _telegramMock .Setup(x => x.SendNewTenantSignupAsync(It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); _globalNotificationMock .Setup(x => x.CreateNotificationAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); _metricsRepoMock .Setup(x => x.RecordUserLoginAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); } private static TenantConfiguration DefaultConfig() => TenantConfiguration.CreateDefault(TenantId); // ------------------------------------------------------------------------- // HandleNewTenantDomainAsync // ------------------------------------------------------------------------- // ── RemoveGlobalAdmin cuts live SignalR streams (join-time-only group authz) ── [Fact] public async Task RemoveGlobalAdmin_DisconnectsTheRemovedAdminsSignalRConnections() { var (req, ctx) = BuildAuthenticatedRequest(callerUpn: "operator@contoso.com"); var response = await _sut.RemoveGlobalAdmin(req, "Removed@Contoso.com", ctx); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(new[] { "removed@contoso.com" }, _signalR.DisconnectedUsers); } [Fact] public async Task RemoveGlobalAdmin_SelfRemovalIsRejectedWithoutDisconnect() { var (req, ctx) = BuildAuthenticatedRequest(callerUpn: "operator@contoso.com"); var response = await _sut.RemoveGlobalAdmin(req, "operator@contoso.com", ctx); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Empty(_signalR.DisconnectedUsers); } private static (HttpRequestData Req, FunctionContext Ctx) BuildAuthenticatedRequest(string callerUpn) { var services = new ServiceCollection(); services.AddOptions(); services.Configure(o => o.Serializer = new Azure.Core.Serialization.JsonObjectSerializer( new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))); var provider = services.BuildServiceProvider(); var principal = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim("preferred_username", callerUpn) }, "test")); var context = new Mock(); context.SetupGet(c => c.Items).Returns( new Dictionary { ["ClaimsPrincipal"] = principal }); context.SetupGet(c => c.InstanceServices).Returns(provider); var req = new Mock(context.Object); req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection()); req.SetupGet(r => r.Body).Returns(new MemoryStream()); req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context.Object)); return (req.Object, context.Object); } private sealed class FakeHttpResponseData : HttpResponseData { public FakeHttpResponseData(FunctionContext context) : base(context) { } public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK; public override HttpHeadersCollection Headers { get; set; } = new(); public override Stream Body { get; set; } = new MemoryStream(); public override HttpCookies Cookies { get; } = new Mock().Object; } [Fact] public async Task HandleNewTenantDomain_WhenUpnDomainIsNotAHostName_DoesNotSeed() { var config = DefaultConfig(); config.DomainName = null!; config.OnboardedBy = null; // The seed is rendered into transactional mails: anything but a strict host name is refused. await _sut.HandleNewTenantDomainAsync(config, TenantId, "user@contoso.com"); Assert.True(string.IsNullOrEmpty(config.DomainName)); Assert.Null(config.OnboardedBy); _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task HandleNewTenantDomain_WhenDomainEmpty_ExtractsAndSaves() { var config = DefaultConfig(); config.DomainName = null!; config.OnboardedBy = null; await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn); Assert.Equal("contoso.com", config.DomainName); Assert.Equal(Upn, config.UpdatedBy); // OnboardedBy is the immutable copy of the first-login UPN that auto-promote on // preview approval reads — UpdatedBy may later be clobbered by background syncs. Assert.Equal(Upn, config.OnboardedBy); _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny(), It.IsAny()), Times.Once); _telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once); _globalNotificationMock.Verify(x => x.CreateNotificationAsync( "preview_signup", "New Tenant Signup", It.Is(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)), $"/admin/tenants/management?tenantId={TenantId}"), Times.Once); // Signup enqueues the delayed auto-approve unconditionally — the worker is the // decision point (flag check at processing time). _autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync( It.Is( e => e.TenantId == TenantId && e.SignupUpn == Upn), AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope.ActivationDelay, It.IsAny()), Times.Once); } // ------------------------------------------------------------------------- // HandleAuthClientIdTrackingAsync — the write must mutate a cache-BYPASSING // read, never the (possibly stale) cached view. Regression for the prod // incident 2026-07-31: the login right after an app-homing flip carries the // changed audience; blind-saving the stale cached entity reverted the flip. // ------------------------------------------------------------------------- private const string LegacyAppId = "1a400946-62c1-4ab4-aa37-f730ac89704d"; private const string PrimaryAppId = "886ab5e2-6144-442c-80cc-9b28e0667731"; [Fact] public async Task HandleAuthClientIdTracking_MutatesFreshConfig_PreservingConcurrentHomingFlip() { // Cached view: pre-flip (homing null, last-seen legacy). Fresh row: flipped meanwhile. var cached = DefaultConfig(); cached.DomainName = "contoso.com"; cached.LastAuthClientId = LegacyAppId; cached.HomedAppClientId = null; var fresh = DefaultConfig(); fresh.DomainName = "contoso.com"; fresh.LastAuthClientId = LegacyAppId; fresh.HomedAppClientId = PrimaryAppId; // the concurrent flip that must survive _tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh); await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}"); // The FRESH entity was mutated and saved — the flip survives, the stale view is discarded. _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(fresh, It.IsAny(), It.IsAny()), Times.Once); _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(cached, It.IsAny(), It.IsAny()), Times.Never); Assert.Equal(PrimaryAppId, fresh.HomedAppClientId); Assert.Equal(PrimaryAppId, fresh.LastAuthClientId); Assert.NotNull(fresh.LastAuthClientIdSince); } [Fact] public async Task HandleAuthClientIdTracking_FreshAlreadyCurrent_DoesNotWrite() { // Another instance already recorded the new client id — the fresh re-check must // suppress the redundant write even though the cached view still looks outdated. var cached = DefaultConfig(); cached.DomainName = "contoso.com"; cached.LastAuthClientId = LegacyAppId; var fresh = DefaultConfig(); fresh.DomainName = "contoso.com"; fresh.LastAuthClientId = PrimaryAppId; _tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh); await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, PrimaryAppId); _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task HandleAuthClientIdTracking_UnchangedOnCachedView_SkipsFreshRead() { // Steady state (same app as last time) must stay a zero-I/O no-op. var cached = DefaultConfig(); cached.DomainName = "contoso.com"; cached.LastAuthClientId = PrimaryAppId; await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}"); _tenantConfigMock.Verify(x => x.GetConfigurationFreshAsync(It.IsAny()), Times.Never); _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task HandleNewTenantDomain_PreservesExistingOnboardedBy() { // Belt-and-suspenders: the method exits early when DomainName is set, but if // anyone ever loosens that guard, OnboardedBy must still be immutable once set. var config = DefaultConfig(); config.DomainName = null!; config.OnboardedBy = "original.requester@contoso.com"; await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn); Assert.Equal("original.requester@contoso.com", config.OnboardedBy); } [Fact] public async Task HandleNewTenantDomain_WhenDomainAlreadySet_NoOp() { var config = DefaultConfig(); config.DomainName = "existing.com"; await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn); _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task HandleNewTenantDomain_WhenUpnEmpty_NoOp() { var config = DefaultConfig(); config.DomainName = null!; await _sut.HandleNewTenantDomainAsync(config, TenantId, ""); _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task HandleNewTenantDomain_WhenUpnHasNoDomain_NoOp() { var config = DefaultConfig(); config.DomainName = null!; await _sut.HandleNewTenantDomainAsync(config, TenantId, "nodomain"); _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task HandleNewTenantDomain_TelegramFailure_DoesNotThrow() { var config = DefaultConfig(); config.DomainName = null!; _telegramMock .Setup(x => x.SendNewTenantSignupAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new HttpRequestException("Telegram unreachable")); // Should not throw — Telegram is fire-and-forget await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn); // SaveConfig should still have been called before the fire-and-forget _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny(), It.IsAny()), Times.Once); } // ------------------------------------------------------------------------- // HandleAutoReEnableAsync // ------------------------------------------------------------------------- [Fact] public async Task HandleAutoReEnable_ExpiredSuspension_ClearsDisabledAndSaves() { var config = DefaultConfig(); config.Disabled = true; config.DisabledReason = "Maintenance"; config.DisabledUntil = DateTime.UtcNow.AddHours(-1); // expired await _sut.HandleAutoReEnableAsync(config, TenantId); Assert.False(config.Disabled); Assert.Null(config.DisabledReason); Assert.Null(config.DisabledUntil); Assert.Equal("System (auto-re-enable)", config.UpdatedBy); _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task HandleAutoReEnable_ActiveSuspension_NoOp() { var config = DefaultConfig(); config.Disabled = true; config.DisabledUntil = DateTime.UtcNow.AddHours(1); // still active await _sut.HandleAutoReEnableAsync(config, TenantId); Assert.True(config.Disabled); _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task HandleAutoReEnable_NotDisabled_NoOp() { var config = DefaultConfig(); config.Disabled = false; await _sut.HandleAutoReEnableAsync(config, TenantId); _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task HandleAutoReEnable_DisabledNoExpiry_NoOp() { var config = DefaultConfig(); config.Disabled = true; config.DisabledUntil = null; // indefinite suspension await _sut.HandleAutoReEnableAsync(config, TenantId); Assert.True(config.Disabled); _tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } // ------------------------------------------------------------------------- // HandlePostDecisionSideEffectsAsync // ------------------------------------------------------------------------- [Fact] public async Task PostDecision_NeedsAutoAdmin_CallsAddTenantAdmin() { _tenantAdminsMock .Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System")) .ReturnsAsync(new TenantAdminRow()); var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true); await _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId); _tenantAdminsMock.Verify(x => x.AddTenantAdminAsync(TenantId, Upn, "System"), Times.Once); } [Fact] public async Task PostDecision_NoAutoAdmin_DoesNotCallAddTenantAdmin() { var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: false); await _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId); _tenantAdminsMock.Verify( x => x.AddTenantAdminAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task PostDecision_AlwaysRecordsMetrics() { var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: false); await _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId); _metricsRepoMock.Verify( x => x.RecordUserLoginAsync(TenantId, Upn, DisplayName, ObjectId), Times.Once); } [Fact] public async Task PostDecision_AutoAdminAndMetrics_BothExecute() { _tenantAdminsMock .Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System")) .ReturnsAsync(new TenantAdminRow()); var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true); await _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId); _tenantAdminsMock.Verify(x => x.AddTenantAdminAsync(TenantId, Upn, "System"), Times.Once); _metricsRepoMock.Verify( x => x.RecordUserLoginAsync(TenantId, Upn, DisplayName, ObjectId), Times.Once); } [Fact] public async Task PostDecision_MetricsFailure_DoesNotThrow() { _metricsRepoMock .Setup(x => x.RecordUserLoginAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("Storage unavailable")); var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: false); // Should not throw — metrics is fire-and-forget await _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId); } [Fact] public async Task PostDecision_AutoAdminFailure_Throws() { _tenantAdminsMock .Setup(x => x.AddTenantAdminAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("Table Storage down")); var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true); // Auto-admin is NOT fire-and-forget — exception must propagate await Assert.ThrowsAsync( () => _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId)); } }