using System.Net;
using System.Security.Claims;
using System.Text;
using AutopilotMonitor.Functions.Functions.Annotations;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure.Core.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Newtonsoft.Json;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
///
/// Pins the session-annotation authorization surface: the per-lane write matrix
/// (), the GA-lane
/// read filter (), and —
/// endpoint-level, through the real request pipeline — that author identity is stamped
/// from the JWT and never from the body (same contract as the gather-rule PUT-upserts).
///
public class SessionAnnotationFunctionsTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
// ── per-lane write matrix ───────────────────────────────────────────────
// Callers reaching the function already passed TenantAdminOrOperator (tenant
// Admin/Operator, or GA via bypass) — the matrix re-gates per lane.
[Theory]
// operator lane: Operator or Tenant Admin; GA (cross-tenant: not tenant admin) may not
[InlineData(AnnotationLanes.Operator, "Operator", false, false, true)]
[InlineData(AnnotationLanes.Operator, "Admin", true, false, true)]
[InlineData(AnnotationLanes.Operator, "GlobalAdmin", false, true, false)]
[InlineData(AnnotationLanes.Operator, "Viewer", false, false, false)]
// tenantadmin lane: Tenant Admin only
[InlineData(AnnotationLanes.TenantAdmin, "Admin", true, false, true)]
[InlineData(AnnotationLanes.TenantAdmin, "Operator", false, false, false)]
[InlineData(AnnotationLanes.TenantAdmin, "GlobalAdmin", false, true, false)]
// globaladmin lane: Global Admin only
[InlineData(AnnotationLanes.GlobalAdmin, "GlobalAdmin", false, true, true)]
[InlineData(AnnotationLanes.GlobalAdmin, "Admin", true, false, false)]
[InlineData(AnnotationLanes.GlobalAdmin, "Operator", false, false, false)]
// unknown lane: never writable
[InlineData("someotherlane", "GlobalAdmin", true, true, false)]
public void IsLaneWritableByCaller_matrix(
string lane, string userRole, bool isTenantAdmin, bool isGlobalAdmin, bool expected)
{
Assert.Equal(expected,
UpsertSessionAnnotationFunction.IsLaneWritableByCaller(lane, userRole, isTenantAdmin, isGlobalAdmin));
}
[Fact]
public void IsLaneWritableByCaller_ga_who_is_also_own_tenant_admin_may_write_tenant_lanes()
{
// A GA annotating a session of their OWN tenant (IsTenantAdmin=true there) may use
// the tenant lanes — the GA-only restriction applies to foreign tenants, where
// IsTenantAdmin is false.
Assert.True(UpsertSessionAnnotationFunction.IsLaneWritableByCaller(
AnnotationLanes.TenantAdmin, "GlobalAdmin", isTenantAdmin: true, isGlobalAdmin: true));
}
// ── GA-lane read filter ─────────────────────────────────────────────────
private static List AllLanes() => new()
{
new SessionAnnotation { Lane = AnnotationLanes.Operator, SessionId = SessionId },
new SessionAnnotation { Lane = AnnotationLanes.TenantAdmin, SessionId = SessionId },
new SessionAnnotation { Lane = AnnotationLanes.GlobalAdmin, SessionId = SessionId },
};
[Fact]
public void FilterLanesForCaller_hides_globaladmin_lane_without_global_scope()
{
var visible = GetSessionAnnotationsFunction.FilterLanesForCaller(AllLanes(), hasGlobalScope: false);
Assert.Equal(2, visible.Count);
Assert.DoesNotContain(visible, a => a.Lane == AnnotationLanes.GlobalAdmin);
}
[Fact]
public void FilterLanesForCaller_returns_all_lanes_for_global_scope()
{
var visible = GetSessionAnnotationsFunction.FilterLanesForCaller(AllLanes(), hasGlobalScope: true);
Assert.Equal(3, visible.Count);
}
// ── GET writableLanes: server-computed write matrix ─────────────────────
// The web renders lanes writable exactly per this list (it holds no matrix copy),
// so the GET must derive it from the SAME function the PUT re-gates with,
// including the own-tenant binding of the tenant-role lanes.
private static GetSessionAnnotationsFunction BuildGetFunction()
{
var annotationRepo = new Mock(MockBehavior.Loose);
annotationRepo.Setup(r => r.GetForSessionAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new List());
var sessionRepo = new Mock(MockBehavior.Loose);
sessionRepo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
return new GetSessionAnnotationsFunction(
NullLogger.Instance,
annotationRepo.Object, sessionRepo.Object);
}
private static async Task RunGetAndReadWritableLanes(RequestContext ctx)
{
var function = BuildGetFunction();
var req = BuildRequest(Principal(("tid", ctx.TenantId)), ctx, new { });
var res = await function.Run(req, SessionId);
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
res.Body.Position = 0;
using var reader = new StreamReader(res.Body);
var json = JsonConvert.DeserializeAnonymousType(
await reader.ReadToEndAsync(), new { writableLanes = Array.Empty() });
return json!.writableLanes;
}
[Fact]
public async Task Get_writableLanes_tenant_admin_own_tenant()
{
var lanes = await RunGetAndReadWritableLanes(TenantAdminContext());
Assert.Equal(new[] { AnnotationLanes.Operator, AnnotationLanes.TenantAdmin }, lanes);
}
[Fact]
public async Task Get_writableLanes_operator_own_tenant()
{
var lanes = await RunGetAndReadWritableLanes(new RequestContext
{
TenantId = TenantId,
TargetTenantId = TenantId,
UserPrincipalName = "op@contoso.com",
UserRole = Constants.TenantRoles.Operator,
});
Assert.Equal(new[] { AnnotationLanes.Operator }, lanes);
}
[Fact]
public async Task Get_writableLanes_ga_on_foreign_session_gets_only_globaladmin_lane()
{
// GA is Admin of their HOME tenant; the session resolves to a foreign tenant —
// the home-tenant role must not leak, exactly like the PUT's 403 test above.
var lanes = await RunGetAndReadWritableLanes(new RequestContext
{
TenantId = "99999999-9999-9999-9999-999999999999",
TargetTenantId = "99999999-9999-9999-9999-999999999999",
UserPrincipalName = "ga@fabrikam.com",
IsGlobalAdmin = true,
IsTenantAdmin = true,
UserRole = Constants.TenantRoles.Admin,
});
Assert.Equal(new[] { AnnotationLanes.GlobalAdmin }, lanes);
}
[Fact]
public async Task Get_writableLanes_global_reader_gets_none()
{
var lanes = await RunGetAndReadWritableLanes(new RequestContext
{
TenantId = "99999999-9999-9999-9999-999999999999",
TargetTenantId = "99999999-9999-9999-9999-999999999999",
UserPrincipalName = "reader@fabrikam.com",
IsGlobalReader = true,
});
Assert.Empty(lanes);
}
// ── global list: GA-lane exclusion follows global scope ─────────────────
// The route is GlobalReadOrAdmin, but the delegated ("MSP") read rescue admits a
// delegated caller on its managed ?tenantId= path — the platform-internal
// globaladmin lane must be excluded server-side for anyone without global scope.
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task GlobalList_derives_globaladmin_lane_exclusion_from_global_scope(
bool hasGlobalScope, bool expectedExclude)
{
var annotationRepo = new Mock(MockBehavior.Loose);
bool? capturedExclude = null;
annotationRepo
.Setup(r => r.QueryPageAsync(
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny()))
.Callback(
(_, _, _, _, _, _, _, _, exclude) => capturedExclude = exclude)
.ReturnsAsync((new List(), (string?)null));
var function = new ListSessionAnnotationsFunction(
NullLogger.Instance, annotationRepo.Object);
var requestCtx = new RequestContext
{
TenantId = TenantId,
TargetTenantId = TenantId,
UserPrincipalName = "reader@contoso.com",
IsGlobalAdmin = hasGlobalScope,
};
var req = BuildRequest(Principal(("tid", TenantId)), requestCtx, new { });
Mock.Get(req).SetupGet(r => r.Url)
.Returns(new Uri("https://localhost/api/global/session-annotations"));
var res = await function.Run(req);
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
Assert.Equal(expectedExclude, capturedExclude);
}
// ── endpoint-level tests (fake HttpRequestData, same harness as
// GatherRulesFunctionAuthorEndpointTests) ──────────────────────────────
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().Object;
}
private static FunctionContext BuildContext(ClaimsPrincipal? principal, RequestContext requestContext)
{
var services = new ServiceCollection();
services.AddOptions();
services.Configure(o => o.Serializer = new JsonObjectSerializer());
var provider = services.BuildServiceProvider();
var items = new Dictionary