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:
Dev
2026-04-09 14:31:58 +02:00
parent 11e835f586
commit 6270fe4605
2 changed files with 359 additions and 5 deletions

View File

@@ -29,6 +29,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
private readonly IBrandingService? _brandingService;
private readonly ISharePointGroupResolver? _groupResolver;
private readonly ILogger<FeatureViewModelBase> _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<FeatureViewModelBase> 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<FeatureViewModelBase> 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<PermissionEntry> 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 ───────────────────────────────────────────────
/// <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 ─────────────────────────────────────────────────────
protected override void OnTenantSwitched(TenantProfile profile)