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