using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Azure; using Azure.Data.Tables; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; using RequestContext = AutopilotMonitor.Functions.Helpers.RequestContext; namespace AutopilotMonitor.Functions.Tests; /// /// Fragilitätsaudit P5.3/P6.1: RequestContextExtensions.ResolveSessionScopeAsync is the /// single implementation of the former 15-site copy-paste session-scope fallback, and /// TableStorageService.ResolveSessionTenantIdAsync replaces the cross-partition /// SessionsIndex scan with a SessionTenantLookup point-read (legacy scan fallback + self-heal). /// These tests pin the semantics every migrated endpoint now inherits. /// public class SessionScopeResolutionTests { private const string HomeTenant = "11111111-1111-1111-1111-111111111111"; private const string OtherTenant = "44444444-4444-4444-4444-444444444444"; private const string SessionId = "22222222-2222-2222-2222-222222222222"; // ── RequestContextExtensions.ResolveSessionScopeAsync ─────────────────────── [Fact] public async Task NonGlobalCaller_KeepsTargetTenant_WithoutTouchingTheRepo() { var repo = new Mock(MockBehavior.Strict); var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, UserRole = Constants.TenantRoles.Admin }; var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId); Assert.Equal(HomeTenant, result); repo.VerifyNoOtherCalls(); } [Fact] public async Task GlobalScope_CrossTenantSession_ResolvesToOwningTenant() { var repo = new Mock(); repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant); var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalReader = true }; var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId); Assert.Equal(OtherTenant, result); } [Fact] public async Task GlobalScope_OwnTenantSession_KeepsTargetTenant() { // Case-insensitive comparison: a resolver returning a different casing must not // count as "different tenant". var repo = new Mock(); repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(HomeTenant.ToUpperInvariant()); var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true }; var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId); Assert.Equal(HomeTenant, result); } [Fact] public async Task GlobalScope_UnknownSession_KeepsTargetTenant_SoEndpointNotFoundHandlingStaysInCharge() { var repo = new Mock(); repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync((string?)null); var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true }; var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId); Assert.Equal(HomeTenant, result); } [Fact] public async Task RequireGlobalAdmin_ExcludesGlobalReader_FromCrossTenantWrites() { // Write paths (annotation upsert) gate the resolve on IsGlobalAdmin: a read-only // Global Reader keeps the middleware-validated tenant and can never steer a write // into a foreign tenant. var repo = new Mock(MockBehavior.Strict); var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalReader = true }; var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true); Assert.Equal(HomeTenant, result); repo.VerifyNoOtherCalls(); } [Fact] public async Task RequireGlobalAdmin_StillResolves_ForGlobalAdmin_WhenTheSessionRowCorroboratesTheLookup() { var repo = new Mock(); repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant); repo.Setup(r => r.GetSessionAsync(OtherTenant, SessionId)) .ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = OtherTenant }); var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true }; var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true); Assert.Equal(OtherTenant, result); } [Fact] public async Task RequireGlobalAdmin_KeepsTargetTenant_WhenTheLookupPointsAtATenantWithoutTheSessionRow() { // A lookup row whose tenant does not hold the Sessions row (poisoned or stale mapping) // must never steer a GA WRITE into that tenant — silently keep the target tenant so the // endpoint's own not-found handling answers; no operator confirmation dialog. var repo = new Mock(); repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant); repo.Setup(r => r.GetSessionAsync(OtherTenant, SessionId)).ReturnsAsync((SessionSummary?)null); var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true }; var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true); Assert.Equal(HomeTenant, result); } [Fact] public async Task ReadPath_DoesNotCorroborate_TheLookupWithASessionRead() { // Reads keep the single point-read contract: the endpoint's own session read is the // corroboration (404 when the row is missing in the resolved tenant). var repo = new Mock(MockBehavior.Strict); repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant); var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true }; var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId); Assert.Equal(OtherTenant, result); repo.VerifyAll(); repo.VerifyNoOtherCalls(); } [Fact] public async Task DelegatedCaller_GetsNoFallback_ByDesign() { // Deliberate: delegated (MSP) callers are NOT global scope — extending the fallback // to AllowedTenantIds would widen the authz surface and is a separate user decision. var repo = new Mock(MockBehavior.Strict); var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = OtherTenant, IsDelegatedReader = true, AllowedTenantIds = new[] { OtherTenant }, }; var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId); Assert.Equal(OtherTenant, result); repo.VerifyNoOtherCalls(); } // ── TableStorageService.ResolveSessionTenantIdAsync ───────────────────────── [Fact] public async Task PointReadHit_ReturnsTenant_WithoutScanningTheIndex() { var harness = new ResolveHarness(lookupRow: LookupRow(OtherTenant)); var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId); Assert.Equal(OtherTenant, result); harness.Index.Verify(t => t.QueryAsync( It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); } [Fact] public async Task PointReadMiss_FallsBackToScan_AndHealsTheLookupRow() { var indexRow = new TableEntity(OtherTenant, "0000000000000000001_" + SessionId) { ["SessionId"] = SessionId }; var harness = new ResolveHarness(lookupRow: null, indexRows: new[] { indexRow }); var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId); Assert.Equal(OtherTenant, result); // Self-heal: the lookup row is written ADD-ONLY so the next resolve is a point-read and // a claim that landed meanwhile is never overwritten. harness.Lookup.Verify(t => t.AddEntityAsync( It.Is(e => e.PartitionKey == SessionId && e.RowKey == "tenant" && e.GetString("TenantId") == OtherTenant), It.IsAny()), Times.Once); harness.Lookup.Verify(t => t.UpsertEntityAsync( It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task SelfHeal_SwallowsAConcurrentClaim_AndStillReturnsTheScannedTenant() { var indexRow = new TableEntity(OtherTenant, "0000000000000000001_" + SessionId) { ["SessionId"] = SessionId }; var harness = new ResolveHarness(lookupRow: null, indexRows: new[] { indexRow }); harness.Lookup .Setup(t => t.AddEntityAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new RequestFailedException(409, "EntityAlreadyExists")); var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId); Assert.Equal(OtherTenant, result); } [Fact] public async Task UnknownSession_ReturnsNull_AndWritesNoHealRow() { var harness = new ResolveHarness(lookupRow: null, indexRows: Array.Empty()); var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId); Assert.Null(result); harness.Lookup.Verify(t => t.AddEntityAsync( It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task InvalidSessionId_Throws_BeforeAnyStorageCall() { var harness = new ResolveHarness(lookupRow: null); await Assert.ThrowsAsync( () => harness.Sut.ResolveSessionTenantIdAsync("not-a-guid")); } // ── RecomputeTriggerGate ───────────────────────────────────────────────────── [Theory] [InlineData("Admin", true)] [InlineData("Operator", true)] [InlineData("Viewer", false)] [InlineData("", false)] public void OwnTenantRoles_GateRecompute(string role, bool expected) { var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, UserRole = role }; Assert.Equal(expected, RecomputeTriggerGate.CanTriggerRecompute(ctx, HomeTenant)); } [Fact] public void GlobalAdmin_MayRecompute_CrossTenant() { var ctx = new RequestContext { TenantId = HomeTenant, IsGlobalAdmin = true }; Assert.True(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant)); } [Fact] public void GlobalReader_MayNotRecompute_Anywhere() { var ctx = new RequestContext { TenantId = HomeTenant, IsGlobalReader = true }; Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, HomeTenant)); Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant)); } [Fact] public void AdminRole_DoesNotCarryCrossTenant() { // A tenant Admin viewing a FOREIGN tenant's session (delegated read) must not be able // to trigger recomputes there — the role binds to the caller's own tenant. var ctx = new RequestContext { TenantId = HomeTenant, UserRole = Constants.TenantRoles.Admin }; Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant)); } // ── Harness ────────────────────────────────────────────────────────────────── private static TableEntity LookupRow(string tenantId) => new(SessionId, "tenant") { ["TenantId"] = tenantId }; private sealed class ResolveHarness { public Mock Lookup { get; } = new(); public Mock Index { get; } = new(); public TableStorageService Sut { get; } public ResolveHarness(TableEntity? lookupRow, TableEntity[]? indexRows = null) { if (lookupRow != null) { Lookup .Setup(t => t.GetEntityAsync(SessionId, "tenant", It.IsAny>(), It.IsAny())) .ReturnsAsync(Response.FromValue(lookupRow, Mock.Of())); } else { Lookup .Setup(t => t.GetEntityAsync(SessionId, "tenant", It.IsAny>(), It.IsAny())) .ThrowsAsync(new RequestFailedException(404, "Not Found")); } Lookup .Setup(t => t.AddEntityAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Mock.Of()); var page = Page.FromValues(indexRows ?? Array.Empty(), null, Mock.Of()); Index .Setup(t => t.QueryAsync( It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .Returns(AsyncPageable.FromPages(new[] { page })); var serviceClient = new Mock(); serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionTenantLookup)).Returns(Lookup.Object); serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionsIndex)).Returns(Index.Object); Sut = new TableStorageService(serviceClient.Object, NullLogger.Instance); } } }