using System.Net; using AutopilotMonitor.Functions.Functions.Infrastructure; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Functions.Tests; /// /// Tests for AuthFunction.BuildAuthResult — the pure decision logic behind GET /api/auth/me. /// Covers all gate checks and edge cases to guard against regressions when parallelizing queries. /// public class AuthFunctionTests { 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 static TenantConfiguration DefaultConfig() => TenantConfiguration.CreateDefault(TenantId); private static McpAccessCheckResult McpAllowed() => McpAccessCheckResult.Allowed(Upn, "test-grant"); private static McpAccessCheckResult McpDenied() => McpAccessCheckResult.Denied("not allowed"); private static MemberRoleInfo AdminRole() => new() { Role = Constants.TenantRoles.Admin, CanManageBootstrapTokens = true }; private static MemberRoleInfo OperatorRole() => new() { Role = Constants.TenantRoles.Operator, CanManageBootstrapTokens = false }; private static MemberRoleInfo ViewerRole() => new() { Role = Constants.TenantRoles.Viewer, CanManageBootstrapTokens = false }; // ------------------------------------------------------------------------- // Happy Path // ------------------------------------------------------------------------- [Fact] public void HappyPath_AdminUser_Returns200WithCorrectFields() { var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: AdminRole(), mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); Assert.Equal(HttpStatusCode.OK, result.StatusCode); Assert.False(result.NeedsAutoAdmin); var body = ToDynamic(result.Body); Assert.Equal(TenantId, (string)body.tenantId); Assert.Equal(Upn, (string)body.upn); Assert.Equal(DisplayName, (string)body.displayName); Assert.Equal(ObjectId, (string)body.objectId); Assert.False((bool)body.isGlobalAdmin); Assert.True((bool)body.isTenantAdmin); Assert.Equal(Constants.TenantRoles.Admin, (string)body.role); Assert.True((bool)body.canManageBootstrapTokens); Assert.True((bool)body.hasMcpAccess); } [Fact] public void HappyPath_SurfacesWhatsNewSeenMarks() { var platformSeen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc); var agentSeen = new DateTime(2026, 9, 8, 10, 5, 0, DateTimeKind.Utc); var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: AdminRole(), mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId, whatsNewSeenPlatformUtc: platformSeen, whatsNewSeenAgentUtc: agentSeen); Assert.True(result.IsSuccess); var body = ToDynamic(result.Body); Assert.Equal(platformSeen, (DateTime)body.whatsNewSeenPlatformUtc); Assert.Equal(agentSeen, (DateTime)body.whatsNewSeenAgentUtc); } [Theory] [InlineData(true)] [InlineData(false)] public void HappyPath_CarriesThePendingTermsAndDpaAcceptance(bool pending) { var config = DefaultConfig(); config.DpaAcceptancePending = pending; var result = AuthFunction.BuildAuthResult( config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: AdminRole(), mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); Assert.Equal(pending, (bool)ToDynamic(result.Body).dpaAcceptancePending); } [Theory] [InlineData("platform", "platform")] [InlineData("Platform", "platform")] [InlineData("agent", "agent")] public void TryNormalizeWhatsNewSeen_AcceptsKnownChannels(string input, string expected) { var seen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc); var ok = AuthFunction.TryNormalizeWhatsNewSeen( new WhatsNewSeenRequest { Channel = input, SeenUtc = seen }, seen.AddMinutes(1), out var channel, out var seenUtc, out var error); Assert.True(ok, error); Assert.Equal(expected, channel); Assert.Equal(seen, seenUtc); } [Fact] public void TryNormalizeWhatsNewSeen_RejectsUnknownChannel() { var ok = AuthFunction.TryNormalizeWhatsNewSeen( new WhatsNewSeenRequest { Channel = "other", SeenUtc = DateTime.UtcNow }, DateTime.UtcNow, out _, out _, out var error); Assert.False(ok); Assert.Equal("channel must be 'platform' or 'agent'", error); } [Fact] public void TryNormalizeWhatsNewSeen_ClampsFarFutureTimestamp() { var now = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc); var ok = AuthFunction.TryNormalizeWhatsNewSeen( new WhatsNewSeenRequest { Channel = "agent", SeenUtc = now.AddMinutes(6) }, now, out _, out var seenUtc, out var error); Assert.True(ok, error); Assert.Equal(now, seenUtc); } [Fact] public void HappyPath_OperatorUser_ReturnsCorrectRole() { var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: OperatorRole(), mcpCheck: McpDenied(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); var body = ToDynamic(result.Body); Assert.Equal(Constants.TenantRoles.Operator, (string)body.role); Assert.False((bool)body.isTenantAdmin); Assert.False((bool)body.canManageBootstrapTokens); Assert.False((bool)body.hasMcpAccess); } [Fact] public void HappyPath_ViewerUser_ReturnsCorrectRole() { var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: ViewerRole(), mcpCheck: McpDenied(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); var body = ToDynamic(result.Body); Assert.Equal(Constants.TenantRoles.Viewer, (string)body.role); Assert.False((bool)body.isTenantAdmin); } [Fact] public void HappyPath_GlobalAdmin_ReturnsIsGlobalAdminTrue() { var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: true, isGlobalReader: false, isPreviewApproved: true, memberRole: AdminRole(), mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); var body = ToDynamic(result.Body); Assert.True((bool)body.isGlobalAdmin); } [Fact] public void HappyPath_GlobalReader_ReturnsIsGlobalReaderTrue_NotGlobalAdmin() { var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: true, isPreviewApproved: true, memberRole: null, mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); var body = ToDynamic(result.Body); Assert.False((bool)body.isGlobalAdmin); Assert.True((bool)body.isGlobalReader); } [Fact] public void PreviewGate_GlobalReaderBypassesPreviewGate() { // A read-only platform reader is staff and must bypass the private-preview gate, exactly like a GA. var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: true, isPreviewApproved: false, memberRole: null, mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); } [Fact] public void AutoAdmin_NeverTriggered_ForGlobalReader_EvenAsFirstUser() { // GlobalReader is additive (it never removes tenant rights) but must not SILENTLY acquire new // write power: the first-login auto-admin convenience is declined for a read-only-flagged // identity. (Explicit TenantAdmins membership would still grant write — that's the additive path.) var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: true, isPreviewApproved: true, memberRole: null, mcpCheck: McpAllowed(), hasTenantAdmins: false, // first user in this tenant TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); Assert.False(result.NeedsAutoAdmin); var body = ToDynamic(result.Body); Assert.False((bool)body.isTenantAdmin); } // ------------------------------------------------------------------------- // Gate 1: Suspended Tenant // ------------------------------------------------------------------------- [Fact] public void SuspendedTenant_Returns403WithTenantSuspendedError() { var config = DefaultConfig(); config.Disabled = true; var result = AuthFunction.BuildAuthResult( config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: AdminRole(), mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.False(result.IsSuccess); Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode); var body = ToDynamic(result.Body); Assert.Equal("TenantSuspended", (string)body.error); } [Fact] public void SuspendedTenant_WithCustomReason_ReturnsCustomMessage() { var config = DefaultConfig(); config.Disabled = true; config.DisabledReason = "Terms of service violation"; var result = AuthFunction.BuildAuthResult( config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: AdminRole(), mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.False(result.IsSuccess); var body = ToDynamic(result.Body); Assert.Equal("Terms of service violation", (string)body.message); } [Fact] public void SuspendedTenant_BlocksEvenGlobalAdmin() { var config = DefaultConfig(); config.Disabled = true; var result = AuthFunction.BuildAuthResult( config, isGlobalAdmin: true, isGlobalReader: false, isPreviewApproved: true, memberRole: AdminRole(), mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.False(result.IsSuccess); Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode); var body = ToDynamic(result.Body); Assert.Equal("TenantSuspended", (string)body.error); } [Fact] public void SuspendedTenant_WithExpiredDisabledUntil_IsNotBlocked() { var config = DefaultConfig(); config.Disabled = true; config.DisabledUntil = DateTime.UtcNow.AddHours(-1); // expired var result = AuthFunction.BuildAuthResult( config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: AdminRole(), mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); } // ------------------------------------------------------------------------- // Gate 2: Activation Gate // ------------------------------------------------------------------------- [Fact] public void PreviewGate_NotApproved_Returns403PendingActivation() { var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: false, memberRole: AdminRole(), mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.False(result.IsSuccess); Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode); var body = ToDynamic(result.Body); Assert.Equal("PendingActivation", (string)body.error); } [Fact] public void PreviewGate_GlobalAdminBypassesPreviewGate() { var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: true, isGlobalReader: false, isPreviewApproved: false, memberRole: AdminRole(), mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); } [Fact] public void PreviewGate_DelegatedAdminBypasses_AndSurfacesManagedTenants() { // An MSP admin whose own home tenant is NOT preview-approved still logs in (explicitly authorized // to manage OTHER tenants), and the response carries the managed tenant set + isDelegated flag. const string managed = "22222222-2222-2222-2222-222222222222"; var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: false, memberRole: null, mcpCheck: McpDenied(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId, delegatedTenantIds: new[] { managed }); Assert.True(result.IsSuccess); var body = ToDynamic(result.Body); Assert.True((bool)body.isDelegated); Assert.Contains(managed, (IReadOnlyCollection)body.delegatedTenantIds); } [Fact] public void NonDelegatedUser_HasEmptyDelegatedTenantIds() { var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: AdminRole(), mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); var body = ToDynamic(result.Body); Assert.False((bool)body.isDelegated); Assert.Empty((IReadOnlyCollection)body.delegatedTenantIds); } [Fact] public void GatePriority_SuspendedTakesPrecedenceOverDelegatedBypass() { // Gate 1 (suspended home tenant) still wins over the delegated preview-gate bypass. var config = DefaultConfig(); config.Disabled = true; var result = AuthFunction.BuildAuthResult( config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: false, memberRole: null, mcpCheck: McpDenied(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId, delegatedTenantIds: new[] { "22222222-2222-2222-2222-222222222222" }); Assert.False(result.IsSuccess); var body = ToDynamic(result.Body); Assert.Equal("TenantSuspended", (string)body.error); } [Fact] public void AutoAdmin_DelegatedFirstUser_DoesNotAutoAdminHomeTenant() { // A delegated ("MSP") admin logging into a home tenant with NO existing admins must NOT be silently // made its Tenant Admin — that would convert a read-only delegated assignment (for OTHER tenants) // into write authority over the home tenant. Regression for the preview-gate bypass opening the // auto-admin path to delegated callers. var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: false, memberRole: null, mcpCheck: McpDenied(), hasTenantAdmins: false, TenantId, Upn, DisplayName, ObjectId, delegatedTenantIds: new[] { "22222222-2222-2222-2222-222222222222" }); Assert.True(result.IsSuccess); Assert.False(result.NeedsAutoAdmin); var body = ToDynamic(result.Body); Assert.False((bool)body.isTenantAdmin); Assert.Null((string?)body.role); } // ------------------------------------------------------------------------- // Auto-Admin Logic // ------------------------------------------------------------------------- [Fact] public void AutoAdmin_FirstUser_NoExistingAdmins_BecomesAdmin() { var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: null, // no role yet mcpCheck: McpDenied(), hasTenantAdmins: false, // no existing admins TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); Assert.True(result.NeedsAutoAdmin); var body = ToDynamic(result.Body); Assert.True((bool)body.isTenantAdmin); Assert.Equal(Constants.TenantRoles.Admin, (string)body.role); Assert.True((bool)body.canManageBootstrapTokens); } [Fact] public void AutoAdmin_NotTriggered_WhenAdminsExist() { var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: null, // no role — new user but admins exist mcpCheck: McpDenied(), hasTenantAdmins: true, // admins already exist TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); Assert.False(result.NeedsAutoAdmin); var body = ToDynamic(result.Body); Assert.False((bool)body.isTenantAdmin); Assert.Null((string?)body.role); } [Fact] public void AutoAdmin_NotTriggered_ForClaimDerivedOperator_WhenNoTableMembers() { // Regression guard: a claim-derived role (no table members yet) must NOT auto-promote the // user into the TenantAdmins table. needsAutoAdmin keys off memberRole == null, not on // "not admin", so an Entra app-role Operator in a claim-only tenant stays an Operator. var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: OperatorRole(), // came from the "roles" claim mcpCheck: McpDenied(), hasTenantAdmins: false, // no explicit TenantAdmins table rows TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); Assert.False(result.NeedsAutoAdmin); var body = ToDynamic(result.Body); Assert.False((bool)body.isTenantAdmin); Assert.Equal(Constants.TenantRoles.Operator, (string)body.role); } [Fact] public void AutoAdmin_NotTriggered_ForClaimDerivedViewer_WhenNoTableMembers() { // Same guard for the read-only claim role: an Entra app-role Viewer in a claim-only tenant // stays a Viewer and is never written into the table as Admin. var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: ViewerRole(), // came from the "roles" claim mcpCheck: McpDenied(), hasTenantAdmins: false, TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); Assert.False(result.NeedsAutoAdmin); var body = ToDynamic(result.Body); Assert.False((bool)body.isTenantAdmin); Assert.Equal(Constants.TenantRoles.Viewer, (string)body.role); } [Fact] public void AutoAdmin_NotTriggered_WhenUserAlreadyAdmin() { var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: AdminRole(), mcpCheck: McpDenied(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); Assert.True(result.IsSuccess); Assert.False(result.NeedsAutoAdmin); var body = ToDynamic(result.Body); Assert.True((bool)body.isTenantAdmin); } // ------------------------------------------------------------------------- // MCP Access // ------------------------------------------------------------------------- [Fact] public void McpAccess_Allowed_ReturnsTrue() { var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: AdminRole(), mcpCheck: McpAllowed(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); var body = ToDynamic(result.Body); Assert.True((bool)body.hasMcpAccess); } [Fact] public void McpAccess_Denied_ReturnsFalse() { var result = AuthFunction.BuildAuthResult( DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true, memberRole: AdminRole(), mcpCheck: McpDenied(), hasTenantAdmins: true, TenantId, Upn, DisplayName, ObjectId); var body = ToDynamic(result.Body); Assert.False((bool)body.hasMcpAccess); } // ------------------------------------------------------------------------- // Gate Priority: Suspended takes precedence over Preview // ------------------------------------------------------------------------- [Fact] public void GatePriority_SuspendedTakesPrecedenceOverPreviewGate() { var config = DefaultConfig(); config.Disabled = true; var result = AuthFunction.BuildAuthResult( config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: false, memberRole: null, mcpCheck: McpDenied(), hasTenantAdmins: false, TenantId, Upn, DisplayName, ObjectId); Assert.False(result.IsSuccess); var body = ToDynamic(result.Body); Assert.Equal("TenantSuspended", (string)body.error); } // ------------------------------------------------------------------------- // ExtractDomainFromUpn // ------------------------------------------------------------------------- [Theory] [InlineData("user@contoso.com", "contoso.com")] [InlineData("admin@sub.domain.org", "sub.domain.org")] [InlineData("", "")] [InlineData("noemail", "")] public void ExtractDomainFromUpn_ReturnsExpected(string upn, string expected) { Assert.Equal(expected, AuthFunction.ExtractDomainFromUpn(upn)); } // ------------------------------------------------------------------------- // Helper // ------------------------------------------------------------------------- /// /// Exposes a body (typed DTO or anonymous error shape) as dynamic under its WIRE key names /// (camelCase), so the asserts read the same casing a client would. /// private static dynamic ToDynamic(object obj) { var type = obj.GetType(); var dict = new System.Dynamic.ExpandoObject() as IDictionary; foreach (var prop in type.GetProperties()) { dict[System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(prop.Name)] = prop.GetValue(obj); } return dict; } }