mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
383d0694fe
Four-tier model: Member (Progress Portal only) < Viewer (sees everything,
changes nothing) < Operator < Admin. Backend policies already treated
Viewer correctly (MemberRead admits it, every write tier excludes it) —
this change makes the tier reachable and safe:
- role assignment now validates against Constants.TenantRoles
(case-insensitive, canonicalized, 400 on unknown) — previously ANY string
was persisted; also fixes the case-sensitive last-admin guard
- Viewer is assignable in all four role dropdowns; About Roles describes
the four tiers
- shared hasTenantReadScope() replaces four hand-rolled predicates
(AuthGate, dashboard x2, useDashboardSessions); Viewers land on the
dashboard and the full navbar/sidebar ("adminOrOperator" visibility
renamed to "tenantMember"); Settings admits Viewer read-only
- "Analyze now" stays Admin/GA/Operator (recompute trigger, not a view)
- deleted dead IsTenantMemberAsync (meant Admin-or-Operator — the inverse
of RequestContext.IsTenantMemberRole)
- 16 endpoint tests for role validation, 15 tests for the scope helper
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
210 lines
8.9 KiB
C#
210 lines
8.9 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using AutopilotMonitor.Functions.Functions.Admin;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using Azure.Core.Serialization;
|
|
using Microsoft.Azure.Functions.Worker;
|
|
using Microsoft.Azure.Functions.Worker.Http;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Endpoint-level tests for <see cref="TenantAdminManagementFunction"/> role handling:
|
|
/// the POST/PATCH role allow-list (Admin/Operator/Viewer) matches case-insensitively,
|
|
/// canonicalizes to the exact constant casing before anything reaches storage, and
|
|
/// rejects unknown roles with 400. Uses the same fake-HttpRequestData harness as
|
|
/// <c>SessionAnnotationFunctionsTests</c>.
|
|
/// </summary>
|
|
public class TenantAdminManagementFunctionTests
|
|
{
|
|
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
|
private const string CallerUpn = "admin@contoso.com";
|
|
|
|
// ── TryCanonicalizeRole ─────────────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData("Admin", "Admin")]
|
|
[InlineData("admin", "Admin")]
|
|
[InlineData("OPERATOR", "Operator")]
|
|
[InlineData("operator", "Operator")]
|
|
[InlineData("viewer", "Viewer")]
|
|
[InlineData("Viewer", "Viewer")]
|
|
[InlineData("VIEWER", "Viewer")]
|
|
[InlineData("Member", null)]
|
|
[InlineData("GlobalAdmin", null)]
|
|
[InlineData("", null)]
|
|
public void TryCanonicalizeRole_matches_allow_list_case_insensitively(string input, string? expected)
|
|
{
|
|
Assert.Equal(expected, TenantAdminManagementFunction.TryCanonicalizeRole(input));
|
|
}
|
|
|
|
// ── POST /tenants/{tenantId}/admins ─────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Post_lowercase_viewer_is_persisted_as_canonical_Viewer()
|
|
{
|
|
var h = BuildHarness();
|
|
var (req, ctx) = BuildRequest(new { upn = "user@contoso.com", role = "viewer" });
|
|
|
|
var response = await h.Function.AddTenantAdmin(req, TenantId, ctx);
|
|
|
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
|
h.AdminRepo.Verify(r => r.AddTenantMemberAsync(
|
|
TenantId, "user@contoso.com", CallerUpn, Constants.TenantRoles.Viewer, false), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Post_unknown_role_returns_400_and_never_writes()
|
|
{
|
|
var h = BuildHarness();
|
|
var (req, ctx) = BuildRequest(new { upn = "user@contoso.com", role = "superuser" });
|
|
|
|
var response = await h.Function.AddTenantAdmin(req, TenantId, ctx);
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
h.AdminRepo.Verify(r => r.AddTenantMemberAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()),
|
|
Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Post_missing_role_defaults_to_Admin()
|
|
{
|
|
// Backward compat: older clients post only the UPN.
|
|
var h = BuildHarness();
|
|
var (req, ctx) = BuildRequest(new { upn = "user@contoso.com" });
|
|
|
|
var response = await h.Function.AddTenantAdmin(req, TenantId, ctx);
|
|
|
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
|
h.AdminRepo.Verify(r => r.AddTenantMemberAsync(
|
|
TenantId, "user@contoso.com", CallerUpn, Constants.TenantRoles.Admin, false), Times.Once);
|
|
}
|
|
|
|
// ── PATCH /tenants/{tenantId}/admins/{adminUpn}/permissions ─────────────
|
|
|
|
[Fact]
|
|
public async Task Patch_lowercase_viewer_is_persisted_as_canonical_Viewer()
|
|
{
|
|
var h = BuildHarness();
|
|
var (req, ctx) = BuildRequest(new { role = "viewer", canManageBootstrapTokens = false });
|
|
|
|
var response = await h.Function.UpdateMemberPermissions(req, TenantId, "user@contoso.com", ctx);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
h.AdminRepo.Verify(r => r.UpdateMemberPermissionsAsync(
|
|
TenantId, "user@contoso.com", Constants.TenantRoles.Viewer, false), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Patch_unknown_role_returns_400_and_never_writes()
|
|
{
|
|
var h = BuildHarness();
|
|
var (req, ctx) = BuildRequest(new { role = "root", canManageBootstrapTokens = true });
|
|
|
|
var response = await h.Function.UpdateMemberPermissions(req, TenantId, "user@contoso.com", ctx);
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
h.AdminRepo.Verify(r => r.UpdateMemberPermissionsAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Patch_self_demotion_check_uses_canonical_role()
|
|
{
|
|
// Caller demotes THEMSELVES with a lowercase "admin" — canonicalization must
|
|
// recognize it as Admin (no demotion) and not trip the last-admin guard.
|
|
var h = BuildHarness();
|
|
var (req, ctx) = BuildRequest(new { role = "admin", canManageBootstrapTokens = true });
|
|
|
|
var response = await h.Function.UpdateMemberPermissions(req, TenantId, CallerUpn, ctx);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
h.AdminRepo.Verify(r => r.UpdateMemberPermissionsAsync(
|
|
TenantId, CallerUpn, Constants.TenantRoles.Admin, true), Times.Once);
|
|
}
|
|
|
|
// ── harness ─────────────────────────────────────────────────────────────
|
|
|
|
private sealed class Harness
|
|
{
|
|
public TenantAdminManagementFunction Function = default!;
|
|
public Mock<IAdminRepository> AdminRepo = default!;
|
|
}
|
|
|
|
private static Harness BuildHarness()
|
|
{
|
|
var adminRepo = new Mock<IAdminRepository>(MockBehavior.Loose);
|
|
adminRepo.Setup(r => r.AddTenantMemberAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()))
|
|
.ReturnsAsync(true);
|
|
adminRepo.Setup(r => r.UpdateMemberPermissionsAsync(
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()))
|
|
.ReturnsAsync(true);
|
|
|
|
var service = new TenantAdminsService(
|
|
adminRepo.Object,
|
|
new MemoryCache(new MemoryCacheOptions()),
|
|
NullLogger<TenantAdminsService>.Instance);
|
|
|
|
var maintenanceRepo = new Mock<IMaintenanceRepository>(MockBehavior.Loose);
|
|
|
|
return new Harness
|
|
{
|
|
Function = new TenantAdminManagementFunction(
|
|
NullLogger<TenantAdminManagementFunction>.Instance, service, maintenanceRepo.Object),
|
|
AdminRepo = adminRepo,
|
|
};
|
|
}
|
|
|
|
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(object body)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddOptions();
|
|
// Mirror the production camelCase policy so camelCase test bodies bind to the
|
|
// PascalCase request models exactly like real web requests do.
|
|
services.Configure<WorkerOptions>(o => o.Serializer =
|
|
new JsonObjectSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)));
|
|
var provider = services.BuildServiceProvider();
|
|
|
|
var requestContext = new RequestContext
|
|
{
|
|
TenantId = TenantId,
|
|
TargetTenantId = TenantId,
|
|
UserPrincipalName = CallerUpn,
|
|
IsTenantAdmin = true,
|
|
UserRole = Constants.TenantRoles.Admin,
|
|
};
|
|
|
|
var context = new Mock<FunctionContext>();
|
|
context.SetupGet(c => c.Items).Returns(
|
|
new Dictionary<object, object> { [RequestContext.ItemsKey] = requestContext });
|
|
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(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(body))));
|
|
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;
|
|
}
|
|
}
|