using AutopilotMonitor.Functions.Functions.Infrastructure; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Moq; 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 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 globalAdminMock = new Mock( adminRepo, cache, Mock.Of>()) { CallBase = false }; _tenantAdminsMock = new Mock( adminRepo, cache, Mock.Of>()) { CallBase = false }; var previewMock = new Mock( configRepo, cache, Mock.Of>()) { 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, cache, Mock.Of>(), globalAdminMock.Object, adminConfigService.Object) { CallBase = false }; _metricsRepoMock = new Mock(); _sut = new AuthFunction( Mock.Of>(), globalAdminMock.Object, _tenantConfigMock.Object, _tenantAdminsMock.Object, _metricsRepoMock.Object, previewMock.Object, _telegramMock.Object, _globalNotificationMock.Object, mcpUserMock.Object); // Default: all fire-and-forget calls succeed _tenantConfigMock .Setup(x => x.SaveConfigurationAsync(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 // ------------------------------------------------------------------------- [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), Times.Once); _telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once); _globalNotificationMock.Verify(x => x.CreateNotificationAsync( "preview_signup", "New Preview Signup", It.Is(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)), null), Times.Once); } [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()), 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()), 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()), 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), 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), 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()), 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()), 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()), Times.Never); } // ------------------------------------------------------------------------- // HandlePostDecisionSideEffectsAsync // ------------------------------------------------------------------------- [Fact] public async Task PostDecision_NeedsAutoAdmin_CallsAddTenantAdmin() { _tenantAdminsMock .Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System")) .ReturnsAsync(new TenantAdminEntity()); 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 TenantAdminEntity()); 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)); } }