diff --git a/SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs b/SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs new file mode 100644 index 0000000..9506122 --- /dev/null +++ b/SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs @@ -0,0 +1,280 @@ +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; + } +} diff --git a/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs index d2ec7c2..dba8661 100644 --- a/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs @@ -29,6 +29,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase private readonly IBrandingService? _brandingService; private readonly ISharePointGroupResolver? _groupResolver; private readonly ILogger _logger; + private readonly SettingsService? _settingsService; + private readonly IOwnershipElevationService? _ownershipService; // ── Observable properties ─────────────────────────────────────────────── @@ -136,7 +138,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase HtmlExportService htmlExportService, IBrandingService brandingService, ILogger logger, - ISharePointGroupResolver? groupResolver = null) + ISharePointGroupResolver? groupResolver = null, + SettingsService? settingsService = null, + IOwnershipElevationService? ownershipService = null) : base(logger) { _permissionsService = permissionsService; @@ -147,6 +151,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase _brandingService = brandingService; _groupResolver = groupResolver; _logger = logger; + _settingsService = settingsService; + _ownershipService = ownershipService; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); @@ -160,7 +166,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase ISiteListService siteListService, ISessionManager sessionManager, ILogger logger, - IBrandingService? brandingService = null) + IBrandingService? brandingService = null, + SettingsService? settingsService = null, + IOwnershipElevationService? ownershipService = null) : base(logger) { _permissionsService = permissionsService; @@ -170,6 +178,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase _htmlExportService = null; _brandingService = brandingService; _logger = logger; + _settingsService = settingsService; + _ownershipService = ownershipService; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); @@ -217,6 +227,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase FolderDepth: FolderDepth, IncludeSubsites: IncludeSubsites); + // Read toggle once before the loop (avoids async in exception filter) + var autoOwnership = await IsAutoTakeOwnershipEnabled(); + int i = 0; foreach (var url in nonEmpty) { @@ -230,9 +243,38 @@ public partial class PermissionsViewModel : FeatureViewModelBase Name = _currentProfile?.Name ?? string.Empty }; - var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct); - var siteEntries = await _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct); - allEntries.AddRange(siteEntries); + bool wasElevated = false; + IReadOnlyList siteEntries; + + try + { + var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct); + siteEntries = await _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct); + } + catch (Exception ex) when (IsAccessDenied(ex) && _ownershipService != null && autoOwnership) + { + _logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", url); + var adminUrl = DeriveAdminUrl(_currentProfile?.TenantUrl ?? url); + var adminProfile = new TenantProfile + { + TenantUrl = adminUrl, + ClientId = _currentProfile?.ClientId ?? string.Empty, + Name = _currentProfile?.Name ?? string.Empty + }; + var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct); + await _ownershipService.ElevateAsync(adminCtx, url, string.Empty, ct); + + // Retry scan with fresh context + var retryCtx = await _sessionManager.GetOrCreateContextAsync(profile, ct); + siteEntries = await _permissionsService.ScanSiteAsync(retryCtx, scanOptions, progress, ct); + wasElevated = true; + } + + if (wasElevated) + allEntries.AddRange(siteEntries.Select(e => e with { WasAutoElevated = true })); + else + allEntries.AddRange(siteEntries); + i++; } @@ -260,6 +302,38 @@ public partial class PermissionsViewModel : FeatureViewModelBase ExportHtmlCommand.NotifyCanExecuteChanged(); } + // ── Auto-ownership helpers ─────────────────────────────────────────────── + + /// + /// Derives the tenant admin URL from a standard tenant URL. + /// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com + /// + internal static string DeriveAdminUrl(string tenantUrl) + { + var uri = new Uri(tenantUrl.TrimEnd('/')); + var host = uri.Host; + if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase)) + return tenantUrl; + var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com", + StringComparison.OrdinalIgnoreCase); + return $"{uri.Scheme}://{adminHost}"; + } + + private static bool IsAccessDenied(Exception ex) + { + if (ex is Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) return true; + if (ex is System.Net.WebException webEx && webEx.Response is System.Net.HttpWebResponse resp + && resp.StatusCode == System.Net.HttpStatusCode.Forbidden) return true; + return false; + } + + private async Task IsAutoTakeOwnershipEnabled() + { + if (_settingsService == null) return false; + var settings = await _settingsService.GetSettingsAsync(); + return settings.AutoTakeOwnership; + } + // ── Tenant switching ───────────────────────────────────────────────────── protected override void OnTenantSwitched(TenantProfile profile)