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))
{