diff --git a/SharepointToolbox.Tests/Services/OwnershipElevationServiceTests.cs b/SharepointToolbox.Tests/Services/OwnershipElevationServiceTests.cs new file mode 100644 index 0000000..db0f30e --- /dev/null +++ b/SharepointToolbox.Tests/Services/OwnershipElevationServiceTests.cs @@ -0,0 +1,69 @@ +using SharepointToolbox.Services; + +namespace SharepointToolbox.Tests.Services; + +[Trait("Category", "Unit")] +public class OwnershipElevationServiceTests +{ + [Fact] + public void OwnershipElevationService_ImplementsIOwnershipElevationService() + { + var service = new OwnershipElevationService(); + Assert.IsAssignableFrom(service); + } + + [Fact] + public void AppSettings_AutoTakeOwnership_DefaultsFalse() + { + var settings = new SharepointToolbox.Core.Models.AppSettings(); + Assert.False(settings.AutoTakeOwnership); + } + + [Fact] + public async Task AppSettings_AutoTakeOwnership_RoundTripsThroughJson() + { + var json = System.Text.Json.JsonSerializer.Serialize( + new SharepointToolbox.Core.Models.AppSettings { AutoTakeOwnership = true }, + new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); + + var loaded = System.Text.Json.JsonSerializer.Deserialize(json, + new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); + + Assert.NotNull(loaded); + Assert.True(loaded!.AutoTakeOwnership); + } + + [Fact] + public void PermissionEntry_WasAutoElevated_DefaultsFalse() + { + var entry = new SharepointToolbox.Core.Models.PermissionEntry( + "Site", "Title", "https://example.com", false, + "User", "user@example.com", "Read", "Direct Permissions", "User"); + + Assert.False(entry.WasAutoElevated); + } + + [Fact] + public void PermissionEntry_WasAutoElevated_TrueWhenSet() + { + var entry = new SharepointToolbox.Core.Models.PermissionEntry( + "Site", "Title", "https://example.com", false, + "User", "user@example.com", "Read", "Direct Permissions", "User", + WasAutoElevated: true); + + Assert.True(entry.WasAutoElevated); + } + + [Fact] + public void PermissionEntry_WithExpression_CopiesWasAutoElevated() + { + var original = new SharepointToolbox.Core.Models.PermissionEntry( + "Site", "Title", "https://example.com", false, + "User", "user@example.com", "Read", "Direct Permissions", "User"); + + var elevated = original with { WasAutoElevated = true }; + + Assert.False(original.WasAutoElevated); + Assert.True(elevated.WasAutoElevated); + } +} diff --git a/SharepointToolbox.Tests/ViewModels/SettingsViewModelOwnershipTests.cs b/SharepointToolbox.Tests/ViewModels/SettingsViewModelOwnershipTests.cs new file mode 100644 index 0000000..73e271a --- /dev/null +++ b/SharepointToolbox.Tests/ViewModels/SettingsViewModelOwnershipTests.cs @@ -0,0 +1,64 @@ +using System.IO; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SharepointToolbox.Infrastructure.Persistence; +using SharepointToolbox.Services; +using SharepointToolbox.ViewModels; +using SharepointToolbox.ViewModels.Tabs; + +namespace SharepointToolbox.Tests.ViewModels; + +[Trait("Category", "Unit")] +public class SettingsViewModelOwnershipTests : IDisposable +{ + private readonly string _tempFile; + + public SettingsViewModelOwnershipTests() + { + _tempFile = Path.GetTempFileName(); + File.Delete(_tempFile); + } + + public void Dispose() + { + if (File.Exists(_tempFile)) File.Delete(_tempFile); + if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp"); + } + + private SettingsViewModel CreateViewModel() + { + var settingsService = new SettingsService(new SettingsRepository(_tempFile)); + var mockBranding = new Mock().Object; + var logger = NullLogger.Instance; + return new SettingsViewModel(settingsService, mockBranding, logger); + } + + [Fact] + public async Task LoadAsync_AutoTakeOwnership_LoadsFalseByDefault() + { + var vm = CreateViewModel(); + + await vm.LoadAsync(); + + Assert.False(vm.AutoTakeOwnership); + } + + [Fact] + public async Task SetAutoTakeOwnership_True_CallsSetAutoTakeOwnershipAsync() + { + // Use a real SettingsService backed by temp file to verify persistence + var settingsService = new SettingsService(new SettingsRepository(_tempFile)); + var mockBranding = new Mock().Object; + var logger = NullLogger.Instance; + var vm = new SettingsViewModel(settingsService, mockBranding, logger); + + await vm.LoadAsync(); + vm.AutoTakeOwnership = true; + + // Small delay to let the fire-and-forget persist + await Task.Delay(100); + + var persisted = await settingsService.GetSettingsAsync(); + Assert.True(persisted.AutoTakeOwnership); + } +} diff --git a/SharepointToolbox/App.xaml.cs b/SharepointToolbox/App.xaml.cs index b0a4e4c..e1b1664 100644 --- a/SharepointToolbox/App.xaml.cs +++ b/SharepointToolbox/App.xaml.cs @@ -161,6 +161,9 @@ public partial class App : Application // Phase 17: Group Expansion services.AddTransient(); + // Phase 18: Auto-Take Ownership + services.AddTransient(); + // Phase 7: User Access Audit services.AddTransient(); services.AddTransient(); diff --git a/SharepointToolbox/Core/Models/AppSettings.cs b/SharepointToolbox/Core/Models/AppSettings.cs index f0538a0..6201515 100644 --- a/SharepointToolbox/Core/Models/AppSettings.cs +++ b/SharepointToolbox/Core/Models/AppSettings.cs @@ -4,4 +4,5 @@ public class AppSettings { public string DataFolder { get; set; } = string.Empty; public string Lang { get; set; } = "en"; + public bool AutoTakeOwnership { get; set; } = false; } diff --git a/SharepointToolbox/Core/Models/PermissionEntry.cs b/SharepointToolbox/Core/Models/PermissionEntry.cs index 11043e8..353d6de 100644 --- a/SharepointToolbox/Core/Models/PermissionEntry.cs +++ b/SharepointToolbox/Core/Models/PermissionEntry.cs @@ -13,5 +13,6 @@ public record PermissionEntry( string UserLogins, // Semicolon-joined login names string PermissionLevels, // Semicolon-joined role names (Limited Access already removed) string GrantedThrough, // "Direct Permissions" | "SharePoint Group: " - string PrincipalType // "SharePointGroup" | "User" | "External User" + string PrincipalType, // "SharePointGroup" | "User" | "External User" + bool WasAutoElevated = false // Set to true when site admin was auto-granted to access this entry ); diff --git a/SharepointToolbox/Services/IOwnershipElevationService.cs b/SharepointToolbox/Services/IOwnershipElevationService.cs new file mode 100644 index 0000000..e9bf48e --- /dev/null +++ b/SharepointToolbox/Services/IOwnershipElevationService.cs @@ -0,0 +1,8 @@ +using Microsoft.SharePoint.Client; + +namespace SharepointToolbox.Services; + +public interface IOwnershipElevationService +{ + Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct); +} diff --git a/SharepointToolbox/Services/OwnershipElevationService.cs b/SharepointToolbox/Services/OwnershipElevationService.cs new file mode 100644 index 0000000..b67e73d --- /dev/null +++ b/SharepointToolbox/Services/OwnershipElevationService.cs @@ -0,0 +1,14 @@ +using Microsoft.Online.SharePoint.TenantAdministration; +using Microsoft.SharePoint.Client; + +namespace SharepointToolbox.Services; + +public class OwnershipElevationService : IOwnershipElevationService +{ + public async Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct) + { + var tenant = new Tenant(tenantAdminCtx); + tenant.SetSiteAdmin(siteUrl, loginName, isSiteAdmin: true); + await tenantAdminCtx.ExecuteQueryAsync(); + } +} diff --git a/SharepointToolbox/Services/SettingsService.cs b/SharepointToolbox/Services/SettingsService.cs index 5353d89..08423aa 100644 --- a/SharepointToolbox/Services/SettingsService.cs +++ b/SharepointToolbox/Services/SettingsService.cs @@ -36,4 +36,11 @@ public class SettingsService settings.DataFolder = path; await _repository.SaveAsync(settings); } + + public async Task SetAutoTakeOwnershipAsync(bool enabled) + { + var settings = await _repository.LoadAsync(); + settings.AutoTakeOwnership = enabled; + await _repository.SaveAsync(settings); + } } diff --git a/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs b/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs index c7e3d02..9260762 100644 --- a/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs @@ -39,6 +39,19 @@ public partial class SettingsViewModel : FeatureViewModelBase } } + private bool _autoTakeOwnership; + public bool AutoTakeOwnership + { + get => _autoTakeOwnership; + set + { + if (_autoTakeOwnership == value) return; + _autoTakeOwnership = value; + OnPropertyChanged(); + _ = _settingsService.SetAutoTakeOwnershipAsync(value); + } + } + private string? _mspLogoPreview; public string? MspLogoPreview { @@ -65,8 +78,10 @@ public partial class SettingsViewModel : FeatureViewModelBase var settings = await _settingsService.GetSettingsAsync(); _selectedLanguage = settings.Lang; _dataFolder = settings.DataFolder; + _autoTakeOwnership = settings.AutoTakeOwnership; OnPropertyChanged(nameof(SelectedLanguage)); OnPropertyChanged(nameof(DataFolder)); + OnPropertyChanged(nameof(AutoTakeOwnership)); var mspLogo = await _brandingService.GetMspLogoAsync(); MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null;