mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
1271658b16
- Get started keeps its tick as the acceptance: the CTA hands ?signupConsent=1 to portal (kept per tab for 30 minutes, removed from the address bar at once), the first auth/me forwards it, and the onboarding write records DpaVersion together with DpaAcceptedBy (first-login UPN) and DpaAcceptedAt. - Any other first sign-in (the navbar "Sign in") sets DpaAcceptancePending on the new row instead. auth/me carries the flag; ProtectedRoute renders a blocking dialog with the same sentence and links as Get started (shared TermsDpaAgreementText) instead of the page; POST auth/dpa-acceptance writes the acceptance for the caller's own tenant only, a no-op 204 when nothing is pending. - Tenants onboarded before this carry no flag and are never asked. Activation is unchanged; the dialog appears after it. - The three new fields are system-owned like OnboardedBy (patch deny-list, revert-protected) and projected by config/all, the GA tenant report and the MCP tenant list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
595 lines
24 KiB
C#
595 lines
24 KiB
C#
using System.Net;
|
|
using AutopilotMonitor.Functions.Functions.Infrastructure;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared;
|
|
using AutopilotMonitor.Shared.Models;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string>)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<string>)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
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static dynamic ToDynamic(object obj)
|
|
{
|
|
var type = obj.GetType();
|
|
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
|
|
foreach (var prop in type.GetProperties())
|
|
{
|
|
dict[System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(prop.Name)] = prop.GetValue(obj);
|
|
}
|
|
return dict;
|
|
}
|
|
}
|