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);
}
}
}