mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
eaef1601d0
Closes the documented read-model follow-up of the typed API contract (51737174). Three categories:
WIRE-IDENTICAL moves/substitutions (parity facts where a literal changed, move-only otherwise): RuleDryRun + trace classes and RuleDryRunVerdict move to Shared/RulesApiModels (DryRunAnalyzeRuleResponse.Result concrete; Evidence/MatchedConditions stay heterogeneous by design); BackfillResult(+Sample)/ReclassificationResult(+Sample)/MaintenanceResult move to Shared/AdminApiModels — the shared MaintenanceJobRunResponse union envelope is split into BackfillJobRunResponse/ReclassifyJobRunResponse (identical key layout, split only for the typed slot); TenantConfigFieldSchema moves as a plain class with the record's constructor signature (Shared has no IsExternalInit — call sites just lowercase their named args); HealthCheck moves (HealthCheckResult stays in Functions, it never hits the wire); GlobalNotificationDto moves (one DTO, both notification endpoints); GetMetricsSummaryAsync builds MetricsSummaryTenantItem instead of the anonymous row ([ProjectedItems] dropped, slot concrete, substitution parity fact incl. the per-item windowDays duplicate; IMetricsRepository signature follows).
UNTYPED ENDPOINTS -> DTO (parity facts mandatory): auth/me success body becomes AuthMeResponse (15 keys in exact wire order; role null-omission fact; blocked arms stay anonymous error shapes; AuthFunctionTests.ToDynamic now exposes wire casing so asserts read camelCase for DTOs and error literals alike). auth/mcp becomes CheckMcpAccessResponse — the four fixed keys are non-nullable strings on McpAccessCheckResult, so the dictionary never emitted null values and WhenWritingNull changes nothing; the four conditional keys are nullable slots in exact insertion order, isGlobalAdmin emitted only as literal true; three facts pin plain/platform+delegated/denied against the old Dictionary literal.
ENTITY-HYGIENE (DELIBERATE wire change, exact-JSON shape pins instead of parity facts): GetGlobalAdminsResponse/AddGlobalAdminResponse serve GlobalAdminRow, GET+POST tenants/{id}/admins serve TenantAdminRow, preview/whitelist serves PreviewWhitelistTenantEntry { tenantId } — partitionKey/rowKey/eTag/timestamp (and the whitelist's garbage approvedAt/approvedBy defaults) are gone from the wire. Every wire instance was synthetically back-mapped from Shared read models (GlobalAdminEntry/TenantMember) anyway; the services now map forward into the rows and the ITableEntity types stay storage-only. Consumer audit (web+MCP) found exactly ONE reader of a table key: AdminConfigContext preview-approval (t.partitionKey -> t.tenantId, same change). Shape pins assert the full JSON and table-key absence.
Guard: TypedResponseGuardTests gains an object-slot reflection ratchet over the reachable wire-type graph (IApiResponse + [WireContract] roots, transitive property walk; [ProjectedItems] slots and dictionary values exempt) with mutation-proven red/green; baseline holds exactly RuleDryRunCondition.Evidence, and stale entries fail the ratchet-down arm.
Codegen: manifest 298->311 exported types, every former unknown slot concrete in both wire-types.generated.ts copies; generator idempotent. MCP adopts: access-guard.ts drops its inline auth/mcp cast for CheckMcpAccessResponse (defensive delegatedTenantIds normalization kept), rules.ts drops the local RuleDryRun view (the dead verdict cast disappears; matchedConditions feeds interpolateRuleTemplate typed), get_tenant_config_schema gets its envelope type. Web adopts: new types/auth.ts shim (TenantRole/HomedApp narrowing over AuthMeResponse), AuthContext.UserInfo derives from the wire type and both duplicated as-cast mapping blocks collapse into one toUserInfo(); health-check page, notification contexts and the TenantAdmin types re-export generated interfaces.
Deliberately untouched: config/all + tenants/{id}/admins stay bare arrays (typed items, envelope would break four array-typed web readers — ime-versions precedent); error bodies stay anonymous; error-body ratchet remains a separate PR; the two documented guard baseline entries stay.
Suites: Backend 5330, Agent 2805, DecisionCore 577, SummaryDialog 44, MCP tsc 0 + 617, Web tsc 0 + vitest 957 + eslint 0; solution builds warning-free.
Deploy note: backend + web + MCP all need deployment; the category-C wire change is only visible in the GA admin UI (preview-approval badges) between backend and web deploy.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
516 lines
23 KiB
C#
516 lines
23 KiB
C#
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;
|
|
|
|
/// <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 Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
|
|
private readonly FakeSignalRNotificationService _signalR;
|
|
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 bindings = new StubAdminIdentityBindingService(bound: true);
|
|
var globalAdminMock = new Mock<GlobalAdminService>(
|
|
adminRepo, bindings, cache, Mock.Of<ILogger<GlobalAdminService>>())
|
|
{ CallBase = false };
|
|
|
|
var delegatedAdminMock = new Mock<DelegatedAdminService>(
|
|
adminRepo,
|
|
bindings,
|
|
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
|
cache, Mock.Of<ILogger<DelegatedAdminService>>())
|
|
{ CallBase = false };
|
|
delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>()))
|
|
.ReturnsAsync(DelegatedScope.Empty);
|
|
|
|
_tenantAdminsMock = new Mock<TenantAdminsService>(
|
|
adminRepo, cache, Mock.Of<ILogger<TenantAdminsService>>())
|
|
{ CallBase = false };
|
|
|
|
var previewMock = new Mock<PreviewWhitelistService>(
|
|
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>(), _tenantConfigMock.Object)
|
|
{ CallBase = false };
|
|
|
|
_telegramMock = new Mock<TelegramNotificationService>(
|
|
new HttpClient(), configRepo, Mock.Of<ILogger<TelegramNotificationService>>())
|
|
{ CallBase = false };
|
|
|
|
_globalNotificationMock = new Mock<GlobalNotificationService>(
|
|
notificationRepo, new FakeSignalRNotificationService(), 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, new StubAdminIdentityBindingService(bound: true), cache, Mock.Of<ILogger<McpUserService>>(),
|
|
globalAdminMock.Object, delegatedAdminMock.Object, adminConfigService.Object)
|
|
{ CallBase = false };
|
|
|
|
_metricsRepoMock = new Mock<IMetricsRepository>();
|
|
_autoApproveEnqueuerMock = new Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer>();
|
|
|
|
_signalR = new FakeSignalRNotificationService();
|
|
_sut = new AuthFunction(
|
|
Mock.Of<ILogger<AuthFunction>>(),
|
|
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<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()),
|
|
new Mock<AdminIdentityResolver>(
|
|
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
|
|
_signalR);
|
|
|
|
// Default: all fire-and-forget calls succeed
|
|
_tenantConfigMock
|
|
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
|
.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
|
|
// -------------------------------------------------------------------------
|
|
|
|
// ── 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<WorkerOptions>(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<FunctionContext>();
|
|
context.SetupGet(c => c.Items).Returns(
|
|
new Dictionary<object, object> { ["ClaimsPrincipal"] = principal });
|
|
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
|
|
|
var req = new Mock<HttpRequestData>(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<HttpCookies>().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@<b>contoso.com</b>");
|
|
|
|
Assert.True(string.IsNullOrEmpty(config.DomainName));
|
|
Assert.Null(config.OnboardedBy);
|
|
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
|
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), 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<string?>(), It.IsAny<string?>()), Times.Once);
|
|
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
|
|
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
|
|
"preview_signup", "New Tenant Signup",
|
|
It.Is<string>(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<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(
|
|
e => e.TenantId == TenantId && e.SignupUpn == Upn),
|
|
AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope.ActivationDelay,
|
|
It.IsAny<CancellationToken>()), 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<string?>(), It.IsAny<string?>()), Times.Once);
|
|
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(cached, It.IsAny<string?>(), It.IsAny<string?>()), 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<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), 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<string>()), Times.Never);
|
|
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), 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<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), 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>(), It.IsAny<string?>(), It.IsAny<string?>()), 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>(), It.IsAny<string?>(), It.IsAny<string?>()), 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, It.IsAny<string?>(), It.IsAny<string?>()), 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<string?>(), It.IsAny<string?>()), 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>(), It.IsAny<string?>(), It.IsAny<string?>()), 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>(), It.IsAny<string?>(), It.IsAny<string?>()), 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>(), It.IsAny<string?>(), It.IsAny<string?>()), 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<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 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<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));
|
|
}
|
|
}
|