Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs
T
Oliver Kieselbach 6bd5728652 feat(platform): public availability — tenant activation replaces Private Preview, optional auto-approve
Backend: shared TenantApprovalService (whitelist add + auto-promote shape-guard +
welcome email), AdminConfiguration.AutoApproveNewTenants (default off, uncached
fail-closed accessor), tenant-auto-approve queue (1-min visibility delay, worker
drops on flag-off/approved/suspended/missing config, approves as "System
(auto-approve)" + TenantAutoApproved ops event), 30s negative whitelist cache,
gate 2 error PendingActivation with mode-neutral message, signup strings and
welcome email without preview wording.

Web: /preview → /activation (SWA 301, robots, AuthGate), AuthContext accepts both
error codes and clears pending state on successful auth/me, activation page polls
every 30s and auto-redirects into the portal, dashboard banner reframed to
"Actively developed" + feedback call, terms/privacy/get-started updated, feature
badges → Beta, admin-config web chain + auto-approve checkbox, OPS_EVENT_TYPES.

Deliberately unchanged: PreviewWhitelist/PreviewConfig tables, internal preview/*
API routes, preview_signup notification type, Microsoft DevPrep preview mentions.

Verified: solution build 0 errors, xUnit 3701/3701, tsc 0 errors, vitest 612/612,
ESLint ratchet holds (0/115), next build exports /activation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:30:57 +02:00

487 lines
20 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_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_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>
/// Converts anonymous object to dynamic for property access in tests.
/// </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[prop.Name] = prop.GetValue(obj);
}
return dict;
}
}