using System.Collections.Generic; using System.Net; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.SharePoint.Client; using Moq; using SharepointToolbox.Core.Messages; using SharepointToolbox.Core.Models; using SharepointToolbox.Services; using SharepointToolbox.ViewModels; using SharepointToolbox.ViewModels.Tabs; namespace SharepointToolbox.Tests.ViewModels; /// /// Tests for auto-elevation logic in PermissionsViewModel scan loop. /// OWN-02: catch access-denied, call ElevateAsync, retry scan, tag entries. /// public class PermissionsViewModelOwnershipTests { /// /// Creates a ServerUnauthorizedAccessException via Activator (the reference assembly /// exposes a different ctor signature than the runtime DLL — use runtime reflection). /// private static ServerUnauthorizedAccessException MakeAccessDeniedException() { var t = typeof(ServerUnauthorizedAccessException); var ctor = t.GetConstructors(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)[0]; return (ServerUnauthorizedAccessException)ctor.Invoke(new object?[] { "Access Denied", "", 0, "", "ServerUnauthorizedAccessException", null, "" }); } private static readonly string SiteUrl = "https://tenant.sharepoint.com/sites/test"; private static readonly string TenantUrl = "https://tenant.sharepoint.com"; private static PermissionsViewModel CreateVm( Mock permissionsSvc, Mock sessionManager, SettingsService? settingsService = null, IOwnershipElevationService? ownershipService = null) { var siteListSvc = new Mock(); var logger = NullLogger.Instance; var vm = new PermissionsViewModel( permissionsSvc.Object, siteListSvc.Object, sessionManager.Object, logger, settingsService: settingsService, ownershipService: ownershipService); WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage( new List { new(SiteUrl, "Test Site") }.AsReadOnly())); vm.SetCurrentProfile(new TenantProfile { Name = "Test", TenantUrl = TenantUrl, ClientId = "client-id" }); return vm; } /// /// When AutoTakeOwnership=false and ScanSiteAsync throws ServerUnauthorizedAccessException, /// the exception propagates. /// [Fact] public async Task ScanLoop_ToggleOff_AccessDenied_ExceptionPropagates() { var permSvc = new Mock(); permSvc .Setup(s => s.ScanSiteAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ThrowsAsync(MakeAccessDeniedException()); var sessionMgr = new Mock(); sessionMgr .Setup(s => s.GetOrCreateContextAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((ClientContext)null!); // No settingsService => toggle OFF (null treated as false) var vm = CreateVm(permSvc, sessionMgr, settingsService: null, ownershipService: null); await Assert.ThrowsAsync( () => vm.TestRunOperationAsync(CancellationToken.None, new Progress())); } /// /// When AutoTakeOwnership=true and scan throws access denied, /// ElevateAsync is called once then ScanSiteAsync is retried. /// [Fact] public async Task ScanLoop_ToggleOn_AccessDenied_ElevatesAndRetries() { var permSvc = new Mock(); var callCount = 0; permSvc .Setup(s => s.ScanSiteAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(() => { callCount++; if (callCount == 1) throw MakeAccessDeniedException(); return new List { new("Site", "Test", SiteUrl, true, "User1", "u1@t.com", "Full Control", "Direct", "User") }; }); var sessionMgr = new Mock(); sessionMgr .Setup(s => s.GetOrCreateContextAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((ClientContext)null!); var elevationSvc = new Mock(); elevationSvc .Setup(e => e.ElevateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var settingsSvc = FakeSettingsServiceWithAutoOwnership(true); var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object); await vm.TestRunOperationAsync(CancellationToken.None, new Progress()); // ElevateAsync called once elevationSvc.Verify( e => e.ElevateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); // ScanSiteAsync called twice (first fails, retry succeeds) permSvc.Verify( s => s.ScanSiteAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Exactly(2)); } /// /// When ScanSiteAsync succeeds on first try, ElevateAsync is never called. /// [Fact] public async Task ScanLoop_ScanSucceeds_ElevateNeverCalled() { var permSvc = new Mock(); permSvc .Setup(s => s.ScanSiteAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(new List { new("Site", "Test", SiteUrl, true, "User1", "u1@t.com", "Full Control", "Direct", "User") }); var sessionMgr = new Mock(); sessionMgr .Setup(s => s.GetOrCreateContextAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((ClientContext)null!); var elevationSvc = new Mock(); var settingsSvc = FakeSettingsServiceWithAutoOwnership(true); var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object); await vm.TestRunOperationAsync(CancellationToken.None, new Progress()); elevationSvc.Verify( e => e.ElevateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } /// /// After successful elevation+retry, returned entries have WasAutoElevated=true. /// [Fact] public async Task ScanLoop_AfterElevation_EntriesTaggedWasAutoElevated() { var permSvc = new Mock(); var callCount = 0; permSvc .Setup(s => s.ScanSiteAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(() => { callCount++; if (callCount == 1) throw MakeAccessDeniedException(); return new List { new("Site", "Test", SiteUrl, true, "User1", "u1@t.com", "Full Control", "Direct", "User") }; }); var sessionMgr = new Mock(); sessionMgr .Setup(s => s.GetOrCreateContextAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((ClientContext)null!); var elevationSvc = new Mock(); elevationSvc .Setup(e => e.ElevateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var settingsSvc = FakeSettingsServiceWithAutoOwnership(true); var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object); await vm.TestRunOperationAsync(CancellationToken.None, new Progress()); Assert.NotEmpty(vm.Results); Assert.All(vm.Results, e => Assert.True(e.WasAutoElevated)); } /// /// If ElevateAsync itself throws, the exception propagates (no infinite retry). /// [Fact] public async Task ScanLoop_ElevationThrows_ExceptionPropagates() { var permSvc = new Mock(); permSvc .Setup(s => s.ScanSiteAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ThrowsAsync(MakeAccessDeniedException()); var sessionMgr = new Mock(); sessionMgr .Setup(s => s.GetOrCreateContextAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((ClientContext)null!); var elevationSvc = new Mock(); elevationSvc .Setup(e => e.ElevateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("Elevation failed")); // ScanSiteAsync always throws access denied (does NOT succeed after elevation throws) var settingsSvc = FakeSettingsServiceWithAutoOwnership(true); var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object); await Assert.ThrowsAsync( () => vm.TestRunOperationAsync(CancellationToken.None, new Progress())); // ScanSiteAsync was called exactly once (no retry after elevation failure) permSvc.Verify( s => s.ScanSiteAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once); } /// /// Validates DeriveAdminUrl logic for standard tenant URL. /// [Theory] [InlineData("https://tenant.sharepoint.com", "https://tenant-admin.sharepoint.com")] [InlineData("https://tenant.sharepoint.com/", "https://tenant-admin.sharepoint.com")] [InlineData("https://tenant-admin.sharepoint.com", "https://tenant-admin.sharepoint.com")] public void DeriveAdminUrl_ReturnsCorrectAdminUrl(string input, string expected) { var result = PermissionsViewModel.DeriveAdminUrl(input); Assert.Equal(expected, result); } // ── Helpers ─────────────────────────────────────────────────────────────── private static SettingsService FakeSettingsServiceWithAutoOwnership(bool enabled) { // Use in-memory temp file var tempFile = System.IO.Path.GetTempFileName(); System.IO.File.Delete(tempFile); var repo = new SharepointToolbox.Infrastructure.Persistence.SettingsRepository(tempFile); var svc = new SettingsService(repo); // Seed via SetAutoTakeOwnershipAsync synchronously svc.SetAutoTakeOwnershipAsync(enabled).GetAwaiter().GetResult(); return svc; } }