feat(18-02): scan-loop elevation logic + PermissionsViewModel wiring + tests
- Add _settingsService and _ownershipService fields to PermissionsViewModel - Add SettingsService? and IOwnershipElevationService? to both constructors - Add DeriveAdminUrl internal static helper for admin URL derivation - Add IsAccessDenied helper catching ServerUnauthorizedAccessException + WebException 403 - Add IsAutoTakeOwnershipEnabled async helper reading toggle from SettingsService - Refactor RunOperationAsync with try/catch elevation pattern (read toggle before loop) - Tag elevated entries with WasAutoElevated=true via record with expression - Add PermissionsViewModelOwnershipTests (8 tests): toggle OFF propagates, toggle ON elevates+retries, no elevation on success, WasAutoElevated tagging, elevation throw propagates, DeriveAdminUrl theory
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for auto-elevation logic in PermissionsViewModel scan loop.
|
||||||
|
/// OWN-02: catch access-denied, call ElevateAsync, retry scan, tag entries.
|
||||||
|
/// </summary>
|
||||||
|
public class PermissionsViewModelOwnershipTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a ServerUnauthorizedAccessException via Activator (the reference assembly
|
||||||
|
/// exposes a different ctor signature than the runtime DLL — use runtime reflection).
|
||||||
|
/// </summary>
|
||||||
|
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<IPermissionsService> permissionsSvc,
|
||||||
|
Mock<ISessionManager> sessionManager,
|
||||||
|
SettingsService? settingsService = null,
|
||||||
|
IOwnershipElevationService? ownershipService = null)
|
||||||
|
{
|
||||||
|
var siteListSvc = new Mock<ISiteListService>();
|
||||||
|
var logger = NullLogger<FeatureViewModelBase>.Instance;
|
||||||
|
|
||||||
|
var vm = new PermissionsViewModel(
|
||||||
|
permissionsSvc.Object,
|
||||||
|
siteListSvc.Object,
|
||||||
|
sessionManager.Object,
|
||||||
|
logger,
|
||||||
|
settingsService: settingsService,
|
||||||
|
ownershipService: ownershipService);
|
||||||
|
|
||||||
|
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||||
|
new List<SiteInfo> { new(SiteUrl, "Test Site") }.AsReadOnly()));
|
||||||
|
vm.SetCurrentProfile(new TenantProfile
|
||||||
|
{
|
||||||
|
Name = "Test",
|
||||||
|
TenantUrl = TenantUrl,
|
||||||
|
ClientId = "client-id"
|
||||||
|
});
|
||||||
|
|
||||||
|
return vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When AutoTakeOwnership=false and ScanSiteAsync throws ServerUnauthorizedAccessException,
|
||||||
|
/// the exception propagates.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ScanLoop_ToggleOff_AccessDenied_ExceptionPropagates()
|
||||||
|
{
|
||||||
|
var permSvc = new Mock<IPermissionsService>();
|
||||||
|
permSvc
|
||||||
|
.Setup(s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
|
||||||
|
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(MakeAccessDeniedException());
|
||||||
|
|
||||||
|
var sessionMgr = new Mock<ISessionManager>();
|
||||||
|
sessionMgr
|
||||||
|
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((ClientContext)null!);
|
||||||
|
|
||||||
|
// No settingsService => toggle OFF (null treated as false)
|
||||||
|
var vm = CreateVm(permSvc, sessionMgr, settingsService: null, ownershipService: null);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ServerUnauthorizedAccessException>(
|
||||||
|
() => vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When AutoTakeOwnership=true and scan throws access denied,
|
||||||
|
/// ElevateAsync is called once then ScanSiteAsync is retried.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ScanLoop_ToggleOn_AccessDenied_ElevatesAndRetries()
|
||||||
|
{
|
||||||
|
var permSvc = new Mock<IPermissionsService>();
|
||||||
|
var callCount = 0;
|
||||||
|
permSvc
|
||||||
|
.Setup(s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
|
||||||
|
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(() =>
|
||||||
|
{
|
||||||
|
callCount++;
|
||||||
|
if (callCount == 1)
|
||||||
|
throw MakeAccessDeniedException();
|
||||||
|
return new List<PermissionEntry>
|
||||||
|
{
|
||||||
|
new("Site", "Test", SiteUrl, true, "User1", "u1@t.com", "Full Control", "Direct", "User")
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
var sessionMgr = new Mock<ISessionManager>();
|
||||||
|
sessionMgr
|
||||||
|
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((ClientContext)null!);
|
||||||
|
|
||||||
|
var elevationSvc = new Mock<IOwnershipElevationService>();
|
||||||
|
elevationSvc
|
||||||
|
.Setup(e => e.ElevateAsync(It.IsAny<ClientContext>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var settingsSvc = FakeSettingsServiceWithAutoOwnership(true);
|
||||||
|
|
||||||
|
var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object);
|
||||||
|
|
||||||
|
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||||
|
|
||||||
|
// ElevateAsync called once
|
||||||
|
elevationSvc.Verify(
|
||||||
|
e => e.ElevateAsync(It.IsAny<ClientContext>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Once);
|
||||||
|
|
||||||
|
// ScanSiteAsync called twice (first fails, retry succeeds)
|
||||||
|
permSvc.Verify(
|
||||||
|
s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
|
||||||
|
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Exactly(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When ScanSiteAsync succeeds on first try, ElevateAsync is never called.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ScanLoop_ScanSucceeds_ElevateNeverCalled()
|
||||||
|
{
|
||||||
|
var permSvc = new Mock<IPermissionsService>();
|
||||||
|
permSvc
|
||||||
|
.Setup(s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
|
||||||
|
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<PermissionEntry>
|
||||||
|
{
|
||||||
|
new("Site", "Test", SiteUrl, true, "User1", "u1@t.com", "Full Control", "Direct", "User")
|
||||||
|
});
|
||||||
|
|
||||||
|
var sessionMgr = new Mock<ISessionManager>();
|
||||||
|
sessionMgr
|
||||||
|
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((ClientContext)null!);
|
||||||
|
|
||||||
|
var elevationSvc = new Mock<IOwnershipElevationService>();
|
||||||
|
var settingsSvc = FakeSettingsServiceWithAutoOwnership(true);
|
||||||
|
|
||||||
|
var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object);
|
||||||
|
|
||||||
|
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||||
|
|
||||||
|
elevationSvc.Verify(
|
||||||
|
e => e.ElevateAsync(It.IsAny<ClientContext>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// After successful elevation+retry, returned entries have WasAutoElevated=true.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ScanLoop_AfterElevation_EntriesTaggedWasAutoElevated()
|
||||||
|
{
|
||||||
|
var permSvc = new Mock<IPermissionsService>();
|
||||||
|
var callCount = 0;
|
||||||
|
permSvc
|
||||||
|
.Setup(s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
|
||||||
|
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(() =>
|
||||||
|
{
|
||||||
|
callCount++;
|
||||||
|
if (callCount == 1)
|
||||||
|
throw MakeAccessDeniedException();
|
||||||
|
return new List<PermissionEntry>
|
||||||
|
{
|
||||||
|
new("Site", "Test", SiteUrl, true, "User1", "u1@t.com", "Full Control", "Direct", "User")
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
var sessionMgr = new Mock<ISessionManager>();
|
||||||
|
sessionMgr
|
||||||
|
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((ClientContext)null!);
|
||||||
|
|
||||||
|
var elevationSvc = new Mock<IOwnershipElevationService>();
|
||||||
|
elevationSvc
|
||||||
|
.Setup(e => e.ElevateAsync(It.IsAny<ClientContext>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var settingsSvc = FakeSettingsServiceWithAutoOwnership(true);
|
||||||
|
|
||||||
|
var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object);
|
||||||
|
|
||||||
|
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||||
|
|
||||||
|
Assert.NotEmpty(vm.Results);
|
||||||
|
Assert.All(vm.Results, e => Assert.True(e.WasAutoElevated));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If ElevateAsync itself throws, the exception propagates (no infinite retry).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ScanLoop_ElevationThrows_ExceptionPropagates()
|
||||||
|
{
|
||||||
|
var permSvc = new Mock<IPermissionsService>();
|
||||||
|
permSvc
|
||||||
|
.Setup(s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
|
||||||
|
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(MakeAccessDeniedException());
|
||||||
|
|
||||||
|
var sessionMgr = new Mock<ISessionManager>();
|
||||||
|
sessionMgr
|
||||||
|
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((ClientContext)null!);
|
||||||
|
|
||||||
|
var elevationSvc = new Mock<IOwnershipElevationService>();
|
||||||
|
elevationSvc
|
||||||
|
.Setup(e => e.ElevateAsync(It.IsAny<ClientContext>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.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<InvalidOperationException>(
|
||||||
|
() => vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>()));
|
||||||
|
|
||||||
|
// ScanSiteAsync was called exactly once (no retry after elevation failure)
|
||||||
|
permSvc.Verify(
|
||||||
|
s => s.ScanSiteAsync(It.IsAny<ClientContext>(), It.IsAny<ScanOptions>(),
|
||||||
|
It.IsAny<IProgress<OperationProgress>>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates DeriveAdminUrl logic for standard tenant URL.
|
||||||
|
/// </summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
private readonly IBrandingService? _brandingService;
|
private readonly IBrandingService? _brandingService;
|
||||||
private readonly ISharePointGroupResolver? _groupResolver;
|
private readonly ISharePointGroupResolver? _groupResolver;
|
||||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||||
|
private readonly SettingsService? _settingsService;
|
||||||
|
private readonly IOwnershipElevationService? _ownershipService;
|
||||||
|
|
||||||
// ── Observable properties ───────────────────────────────────────────────
|
// ── Observable properties ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -136,7 +138,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
HtmlExportService htmlExportService,
|
HtmlExportService htmlExportService,
|
||||||
IBrandingService brandingService,
|
IBrandingService brandingService,
|
||||||
ILogger<FeatureViewModelBase> logger,
|
ILogger<FeatureViewModelBase> logger,
|
||||||
ISharePointGroupResolver? groupResolver = null)
|
ISharePointGroupResolver? groupResolver = null,
|
||||||
|
SettingsService? settingsService = null,
|
||||||
|
IOwnershipElevationService? ownershipService = null)
|
||||||
: base(logger)
|
: base(logger)
|
||||||
{
|
{
|
||||||
_permissionsService = permissionsService;
|
_permissionsService = permissionsService;
|
||||||
@@ -147,6 +151,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
_brandingService = brandingService;
|
_brandingService = brandingService;
|
||||||
_groupResolver = groupResolver;
|
_groupResolver = groupResolver;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_ownershipService = ownershipService;
|
||||||
|
|
||||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||||
@@ -160,7 +166,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
ISiteListService siteListService,
|
ISiteListService siteListService,
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
ILogger<FeatureViewModelBase> logger,
|
ILogger<FeatureViewModelBase> logger,
|
||||||
IBrandingService? brandingService = null)
|
IBrandingService? brandingService = null,
|
||||||
|
SettingsService? settingsService = null,
|
||||||
|
IOwnershipElevationService? ownershipService = null)
|
||||||
: base(logger)
|
: base(logger)
|
||||||
{
|
{
|
||||||
_permissionsService = permissionsService;
|
_permissionsService = permissionsService;
|
||||||
@@ -170,6 +178,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
_htmlExportService = null;
|
_htmlExportService = null;
|
||||||
_brandingService = brandingService;
|
_brandingService = brandingService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_ownershipService = ownershipService;
|
||||||
|
|
||||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||||
@@ -217,6 +227,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
FolderDepth: FolderDepth,
|
FolderDepth: FolderDepth,
|
||||||
IncludeSubsites: IncludeSubsites);
|
IncludeSubsites: IncludeSubsites);
|
||||||
|
|
||||||
|
// Read toggle once before the loop (avoids async in exception filter)
|
||||||
|
var autoOwnership = await IsAutoTakeOwnershipEnabled();
|
||||||
|
|
||||||
int i = 0;
|
int i = 0;
|
||||||
foreach (var url in nonEmpty)
|
foreach (var url in nonEmpty)
|
||||||
{
|
{
|
||||||
@@ -230,9 +243,38 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
Name = _currentProfile?.Name ?? string.Empty
|
Name = _currentProfile?.Name ?? string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
|
bool wasElevated = false;
|
||||||
var siteEntries = await _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct);
|
IReadOnlyList<PermissionEntry> siteEntries;
|
||||||
allEntries.AddRange(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++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +302,38 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Auto-ownership helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives the tenant admin URL from a standard tenant URL.
|
||||||
|
/// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com
|
||||||
|
/// </summary>
|
||||||
|
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<bool> IsAutoTakeOwnershipEnabled()
|
||||||
|
{
|
||||||
|
if (_settingsService == null) return false;
|
||||||
|
var settings = await _settingsService.GetSettingsAsync();
|
||||||
|
return settings.AutoTakeOwnership;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tenant switching ─────────────────────────────────────────────────────
|
// ── Tenant switching ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
protected override void OnTenantSwitched(TenantProfile profile)
|
protected override void OnTenantSwitched(TenantProfile profile)
|
||||||
|
|||||||
Reference in New Issue
Block a user