diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 4ba0ae72..0b6746cd 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -840 +841 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs new file mode 100644 index 00000000..bda6579b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs @@ -0,0 +1,319 @@ +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, 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!; + + await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn); + + Assert.Equal("contoso.com", config.DomainName); + Assert.Equal(Upn, config.UpdatedBy); + _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_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)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs index 4a51dbef..276af8a5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs @@ -82,6 +82,8 @@ public class AuthFunction } // --- Parallel data fetch: all independent queries run concurrently --- + // Fail-fast: if any fetch throws, AggregateException propagates → Azure Functions returns 500. + // This is intentional — all 6 queries are required for the auth decision. var tenantConfigTask = _tenantConfigService.GetConfigurationAsync(tenantId); var isGlobalAdminTask = _globalAdminService.IsGlobalAdminAsync(upn); var isApprovedTask = _previewWhitelistService.IsApprovedAsync(tenantId); @@ -100,43 +102,8 @@ public class AuthFunction var existingAdmins = existingAdminsTask.Result; // --- Side-effects that don't affect the auth decision --- - - // Extract and save domain name from first user if not set - if (string.IsNullOrEmpty(tenantConfig.DomainName) && !string.IsNullOrEmpty(upn)) - { - var domain = ExtractDomainFromUpn(upn); - if (!string.IsNullOrEmpty(domain)) - { - _logger.LogInformation("Setting domain name for tenant {TenantId}: {Domain}", tenantId, domain); - tenantConfig.DomainName = domain; - tenantConfig.UpdatedBy = upn; - await _tenantConfigService.SaveConfigurationAsync(tenantConfig); - - // Notify via Telegram that a new tenant has signed up for Private Preview - _ = _telegramNotificationService.SendNewTenantSignupAsync(tenantId, upn) - .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, - "Fire-and-forget Telegram notification failed for tenant {TenantId}", tenantId), - TaskContinuationOptions.OnlyOnFaulted); - - // Persistent in-app notification for Global Admins — best effort - _ = _globalNotificationService.CreateNotificationAsync( - "preview_signup", - "New Preview Signup", - $"Tenant {tenantId} ({domain}), UPN: {upn}"); - } - } - - // Auto-re-enable: if Disabled was true but DisabledUntil has expired, persist the re-enable - if (tenantConfig.Disabled && !tenantConfig.IsCurrentlyDisabled()) - { - _logger.LogInformation("Tenant {TenantId} auto-re-enabled: DisabledUntil ({DisabledUntil}) has expired", - tenantId, tenantConfig.DisabledUntil?.ToString("o")); - tenantConfig.Disabled = false; - tenantConfig.DisabledReason = null; - tenantConfig.DisabledUntil = null; - tenantConfig.UpdatedBy = "System (auto-re-enable)"; - await _tenantConfigService.SaveConfigurationAsync(tenantConfig); - } + await HandleNewTenantDomainAsync(tenantConfig, tenantId, upn); + await HandleAutoReEnableAsync(tenantConfig, tenantId); // --- Pure decision logic (tested by AuthFunctionTests) --- var decision = BuildAuthResult( @@ -164,17 +131,7 @@ public class AuthFunction } // --- Post-decision side-effects --- - - // Auto-admin: first user becomes admin (flagged by BuildAuthResult) - if (decision.NeedsAutoAdmin) - { - _logger.LogInformation("First user login for tenant {TenantId}: {Upn} - Auto-assigning as admin", tenantId, upn); - await _tenantAdminsService.AddTenantAdminAsync(tenantId, upn, "System"); - } - - // Record user login activity for metrics tracking - _ = _metricsRepo.RecordUserLoginAsync(tenantId, upn, displayName, objectId) - .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget RecordUserLoginAsync failed"), TaskContinuationOptions.OnlyOnFaulted); + await HandlePostDecisionSideEffectsAsync(decision, tenantId, upn, displayName, objectId); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(decision.Body); @@ -288,6 +245,80 @@ public class AuthFunction return response; } + /// + /// If the tenant has no domain name yet, extracts it from the UPN, persists it, + /// and fires best-effort notifications (Telegram + global notification). + /// + internal async Task HandleNewTenantDomainAsync( + TenantConfiguration tenantConfig, string tenantId, string upn) + { + if (!string.IsNullOrEmpty(tenantConfig.DomainName) || string.IsNullOrEmpty(upn)) + return; + + var domain = ExtractDomainFromUpn(upn); + if (string.IsNullOrEmpty(domain)) + return; + + _logger.LogInformation("Setting domain name for tenant {TenantId}: {Domain}", tenantId, domain); + tenantConfig.DomainName = domain; + tenantConfig.UpdatedBy = upn; + await _tenantConfigService.SaveConfigurationAsync(tenantConfig); + + // Fire-and-forget: Telegram + _ = _telegramNotificationService.SendNewTenantSignupAsync(tenantId, upn) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "Fire-and-forget Telegram notification failed for tenant {TenantId}", tenantId), + TaskContinuationOptions.OnlyOnFaulted); + + // Fire-and-forget: Global notification + _ = _globalNotificationService.CreateNotificationAsync( + "preview_signup", + "New Preview Signup", + $"Tenant {tenantId} ({domain}), UPN: {upn}"); + } + + /// + /// If the tenant's suspension has expired (Disabled=true but DisabledUntil is past), + /// clears the disabled state and persists the change. + /// MUST run before BuildAuthResult because it mutates tenantConfig.Disabled. + /// + internal async Task HandleAutoReEnableAsync( + TenantConfiguration tenantConfig, string tenantId) + { + if (!tenantConfig.Disabled || tenantConfig.IsCurrentlyDisabled()) + return; + + _logger.LogInformation( + "Tenant {TenantId} auto-re-enabled: DisabledUntil ({DisabledUntil}) has expired", + tenantId, tenantConfig.DisabledUntil?.ToString("o")); + + tenantConfig.Disabled = false; + tenantConfig.DisabledReason = null; + tenantConfig.DisabledUntil = null; + tenantConfig.UpdatedBy = "System (auto-re-enable)"; + await _tenantConfigService.SaveConfigurationAsync(tenantConfig); + } + + /// + /// Executes post-decision side-effects: auto-admin assignment (awaited) + /// and metrics recording (fire-and-forget). + /// + internal async Task HandlePostDecisionSideEffectsAsync( + AuthDecisionResult decision, string tenantId, string upn, + string? displayName, string? objectId) + { + if (decision.NeedsAutoAdmin) + { + _logger.LogInformation("First user login for tenant {TenantId}: {Upn} - Auto-assigning as admin", tenantId, upn); + await _tenantAdminsService.AddTenantAdminAsync(tenantId, upn, "System"); + } + + _ = _metricsRepo.RecordUserLoginAsync(tenantId, upn, displayName, objectId) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "Fire-and-forget RecordUserLoginAsync failed"), + TaskContinuationOptions.OnlyOnFaulted); + } + /// /// Pure decision logic for auth/me — no I/O, fully testable. /// Takes all pre-fetched data and returns the auth decision. diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs index 53ca5241..dfa41102 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs @@ -32,7 +32,7 @@ public class GlobalAdminService /// /// User Principal Name (e.g., oliver@contoso.com) /// True if the user is a Global Admin - public async Task IsGlobalAdminAsync(string? upn) + public virtual async Task IsGlobalAdminAsync(string? upn) { if (string.IsNullOrWhiteSpace(upn)) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs index b6c8a055..7e461050 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs @@ -60,7 +60,7 @@ public class GlobalNotificationService /// Creates a new persistent notification. Designed to be called fire-and-forget; /// failures are logged but never thrown. /// - public async Task CreateNotificationAsync(string type, string title, string message, string? href = null) + public virtual async Task CreateNotificationAsync(string type, string title, string message, string? href = null) { try { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs b/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs index 30f25879..2dc382be 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs @@ -39,7 +39,7 @@ public class McpUserService /// /// Checks if a user is allowed to access the MCP server based on current policy. /// - public async Task IsAllowedAsync(string? upn) + public virtual async Task IsAllowedAsync(string? upn) { if (string.IsNullOrWhiteSpace(upn)) return McpAccessCheckResult.Denied("Missing UPN"); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs b/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs index 6d327dad..bd6e26f0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs @@ -31,7 +31,7 @@ public class PreviewWhitelistService /// /// Checks whether a tenant is approved for Private Preview (cached). /// - public async Task IsApprovedAsync(string tenantId) + public virtual async Task IsApprovedAsync(string tenantId) { if (string.IsNullOrWhiteSpace(tenantId)) return false; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs index 93eb7af9..85942beb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs @@ -35,7 +35,7 @@ namespace AutopilotMonitor.Functions.Services /// Sends a Telegram message to the configured channel when a new tenant signs up for Private Preview. /// No-op if the webhook URL is not configured in the PreviewConfig table. /// - public async Task SendNewTenantSignupAsync(string tenantId, string upn) + public virtual async Task SendNewTenantSignupAsync(string tenantId, string upn) { try { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs index 07de0b16..5ad68841 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs @@ -75,7 +75,7 @@ public class TenantAdminsService /// Tenant ID /// User Principal Name /// UPN of the admin who is adding this user - public async Task AddTenantAdminAsync(string tenantId, string upn, string addedBy) + public virtual async Task AddTenantAdminAsync(string tenantId, string upn, string addedBy) { tenantId = tenantId.ToLowerInvariant(); upn = upn.ToLowerInvariant(); @@ -147,7 +147,7 @@ public class TenantAdminsService /// /// Gets all Tenant Admins for a specific tenant /// - public async Task> GetTenantAdminsAsync(string tenantId) + public virtual async Task> GetTenantAdminsAsync(string tenantId) { SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); @@ -189,7 +189,7 @@ public class TenantAdminsService /// /// Gets the role info for a tenant member. Returns null if user is not a member. /// - public async Task GetMemberRoleAsync(string tenantId, string? upn) + public virtual async Task GetMemberRoleAsync(string tenantId, string? upn) { if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(upn)) return null; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs index 52e933f7..a9f985f9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs @@ -30,7 +30,7 @@ namespace AutopilotMonitor.Functions.Services /// /// Gets configuration for a tenant (uses cache with 5-minute TTL) /// - public async Task GetConfigurationAsync(string tenantId) + public virtual async Task GetConfigurationAsync(string tenantId) { if (string.IsNullOrEmpty(tenantId)) { @@ -87,7 +87,7 @@ namespace AutopilotMonitor.Functions.Services /// /// Saves configuration for a tenant /// - public async Task SaveConfigurationAsync(TenantConfiguration config) + public virtual async Task SaveConfigurationAsync(TenantConfiguration config) { if (config == null || string.IsNullOrEmpty(config.TenantId)) {