mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
more tests for critical AuthFunction
This commit is contained in:
@@ -1 +1 @@
|
||||
840
|
||||
841
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the side-effect methods extracted from AuthFunction.GetCurrentUser().
|
||||
/// Uses Moq to verify service interactions (domain persistence, auto-re-enable, auto-admin, metrics).
|
||||
/// </summary>
|
||||
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<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
|
||||
private readonly Mock<TelegramNotificationService> _telegramMock;
|
||||
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
|
||||
private readonly Mock<IMetricsRepository> _metricsRepoMock;
|
||||
private readonly AuthFunction _sut;
|
||||
|
||||
public AuthFunctionSideEffectTests()
|
||||
{
|
||||
// Shared interface mocks for constructor injection
|
||||
var adminRepo = Mock.Of<IAdminRepository>();
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var notificationRepo = Mock.Of<INotificationRepository>();
|
||||
var cache = Mock.Of<IMemoryCache>();
|
||||
|
||||
_tenantConfigMock = new Mock<TenantConfigurationService>(
|
||||
configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
|
||||
var globalAdminMock = new Mock<GlobalAdminService>(
|
||||
adminRepo, cache, Mock.Of<ILogger<GlobalAdminService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
_tenantAdminsMock = new Mock<TenantAdminsService>(
|
||||
adminRepo, cache, Mock.Of<ILogger<TenantAdminsService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
var previewMock = new Mock<PreviewWhitelistService>(
|
||||
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
_telegramMock = new Mock<TelegramNotificationService>(
|
||||
new HttpClient(), configRepo, Mock.Of<ILogger<TelegramNotificationService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
_globalNotificationMock = new Mock<GlobalNotificationService>(
|
||||
notificationRepo, Mock.Of<ILogger<GlobalNotificationService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
var adminConfigService = new Mock<AdminConfigurationService>(
|
||||
configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
|
||||
var mcpUserMock = new Mock<McpUserService>(
|
||||
adminRepo, cache, Mock.Of<ILogger<McpUserService>>(),
|
||||
globalAdminMock.Object, adminConfigService.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_metricsRepoMock = new Mock<IMetricsRepository>();
|
||||
|
||||
_sut = new AuthFunction(
|
||||
Mock.Of<ILogger<AuthFunction>>(),
|
||||
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<TenantConfiguration>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_telegramMock
|
||||
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_globalNotificationMock
|
||||
.Setup(x => x.CreateNotificationAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_metricsRepoMock
|
||||
.Setup(x => x.RecordUserLoginAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.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<string>(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<TenantConfiguration>()), Times.Never);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), 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<TenantConfiguration>()), 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<TenantConfiguration>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_TelegramFailure_DoesNotThrow()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = null!;
|
||||
|
||||
_telegramMock
|
||||
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.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<TenantConfiguration>()), 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<TenantConfiguration>()), 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<TenantConfiguration>()), 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<string>(), It.IsAny<string>(), It.IsAny<string>()),
|
||||
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<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.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<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.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<InvalidOperationException>(
|
||||
() => _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the tenant has no domain name yet, extracts it from the UPN, persists it,
|
||||
/// and fires best-effort notifications (Telegram + global notification).
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes post-decision side-effects: auto-admin assignment (awaited)
|
||||
/// and metrics recording (fire-and-forget).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure decision logic for auth/me — no I/O, fully testable.
|
||||
/// Takes all pre-fetched data and returns the auth decision.
|
||||
|
||||
@@ -32,7 +32,7 @@ public class GlobalAdminService
|
||||
/// </summary>
|
||||
/// <param name="upn">User Principal Name (e.g., oliver@contoso.com)</param>
|
||||
/// <returns>True if the user is a Global Admin</returns>
|
||||
public async Task<bool> IsGlobalAdminAsync(string? upn)
|
||||
public virtual async Task<bool> IsGlobalAdminAsync(string? upn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(upn))
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
|
||||
@@ -39,7 +39,7 @@ public class McpUserService
|
||||
/// <summary>
|
||||
/// Checks if a user is allowed to access the MCP server based on current policy.
|
||||
/// </summary>
|
||||
public async Task<McpAccessCheckResult> IsAllowedAsync(string? upn)
|
||||
public virtual async Task<McpAccessCheckResult> IsAllowedAsync(string? upn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(upn))
|
||||
return McpAccessCheckResult.Denied("Missing UPN");
|
||||
|
||||
@@ -31,7 +31,7 @@ public class PreviewWhitelistService
|
||||
/// <summary>
|
||||
/// Checks whether a tenant is approved for Private Preview (cached).
|
||||
/// </summary>
|
||||
public async Task<bool> IsApprovedAsync(string tenantId)
|
||||
public virtual async Task<bool> IsApprovedAsync(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
return false;
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
public async Task SendNewTenantSignupAsync(string tenantId, string upn)
|
||||
public virtual async Task SendNewTenantSignupAsync(string tenantId, string upn)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -75,7 +75,7 @@ public class TenantAdminsService
|
||||
/// <param name="tenantId">Tenant ID</param>
|
||||
/// <param name="upn">User Principal Name</param>
|
||||
/// <param name="addedBy">UPN of the admin who is adding this user</param>
|
||||
public async Task<TenantAdminEntity> AddTenantAdminAsync(string tenantId, string upn, string addedBy)
|
||||
public virtual async Task<TenantAdminEntity> AddTenantAdminAsync(string tenantId, string upn, string addedBy)
|
||||
{
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
upn = upn.ToLowerInvariant();
|
||||
@@ -147,7 +147,7 @@ public class TenantAdminsService
|
||||
/// <summary>
|
||||
/// Gets all Tenant Admins for a specific tenant
|
||||
/// </summary>
|
||||
public async Task<List<TenantAdminEntity>> GetTenantAdminsAsync(string tenantId)
|
||||
public virtual async Task<List<TenantAdminEntity>> GetTenantAdminsAsync(string tenantId)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
|
||||
@@ -189,7 +189,7 @@ public class TenantAdminsService
|
||||
/// <summary>
|
||||
/// Gets the role info for a tenant member. Returns null if user is not a member.
|
||||
/// </summary>
|
||||
public async Task<MemberRoleInfo?> GetMemberRoleAsync(string tenantId, string? upn)
|
||||
public virtual async Task<MemberRoleInfo?> GetMemberRoleAsync(string tenantId, string? upn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(upn))
|
||||
return null;
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Gets configuration for a tenant (uses cache with 5-minute TTL)
|
||||
/// </summary>
|
||||
public async Task<TenantConfiguration> GetConfigurationAsync(string tenantId)
|
||||
public virtual async Task<TenantConfiguration> GetConfigurationAsync(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
@@ -87,7 +87,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Saves configuration for a tenant
|
||||
/// </summary>
|
||||
public async Task SaveConfigurationAsync(TenantConfiguration config)
|
||||
public virtual async Task SaveConfigurationAsync(TenantConfiguration config)
|
||||
{
|
||||
if (config == null || string.IsNullOrEmpty(config.TenantId))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user