chore: archive v1.1 Enhanced Reports milestone
Some checks failed
Release SharePoint Toolbox v2 / release (push) Failing after 14s
Some checks failed
Release SharePoint Toolbox v2 / release (push) Failing after 14s
v1.1 shipped with 4 phases (25 plans), 10/10 requirements complete: - Global site selection (toolbar picker, all tabs consume) - User access audit (Graph people-picker, direct/group/inherited) - Simplified permissions (plain-language labels, risk levels, detail toggle) - Storage visualization (LiveCharts2 pie/donut + bar charts) Post-phase polish: centralized site selection (removed per-tab pickers), claims prefix stripping, StorageMetrics backfill, chart tooltip fix, summary stats in app + HTML exports. 205 tests passing, 10,484 LOC. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -119,7 +119,8 @@ public class UserAccessAuditServiceTests
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(claimLogin, result[0].UserLogin);
|
||||
// Claims prefix is stripped: "i:0#.f|membership|alice@contoso.com" -> "alice@contoso.com"
|
||||
Assert.Equal("alice@contoso.com", result[0].UserLogin);
|
||||
}
|
||||
|
||||
// ── Test 3: Classifies Direct access ─────────────────────────────────────
|
||||
|
||||
@@ -126,161 +126,49 @@ public class GlobalSiteSelectionTests
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/finance", vm.TestGlobalSites[1].Url);
|
||||
}
|
||||
|
||||
// ── Test 3: StorageViewModel pre-fills SiteUrl from first global site ────
|
||||
// ── Test 3: All tabs receive GlobalSites via base class ────────────────
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_WithSites_PreFillsSiteUrlOnStorageTab()
|
||||
public void AllTabs_ReceiveGlobalSites_ViaBaseClass()
|
||||
{
|
||||
// Arrange
|
||||
var vm = CreateStorageViewModel();
|
||||
var storageVm = CreateStorageViewModel();
|
||||
var permissionsVm = CreatePermissionsViewModel();
|
||||
var sites = TwoSites();
|
||||
|
||||
// Act
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SiteUrl);
|
||||
// Assert: base class TestGlobalSites (exposed via TestFeatureViewModel)
|
||||
// is not accessible on concrete VMs, but we can verify by creating another VM
|
||||
var testVm = new TestFeatureViewModel(NullLogger<FeatureViewModelBase>.Instance);
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
Assert.Equal(2, testVm.TestGlobalSites.Count);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", testVm.TestGlobalSites[0].Url);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/finance", testVm.TestGlobalSites[1].Url);
|
||||
}
|
||||
|
||||
// ── Test 4: StorageViewModel local override prevents global update ────────
|
||||
// ── Test 4: GlobalSites updated when new message arrives ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_AfterLocalSiteEntry_DoesNotOverrideSiteUrl()
|
||||
public void GlobalSites_UpdatedOnNewMessage_ReplacesOldSites()
|
||||
{
|
||||
// Arrange
|
||||
var vm = CreateStorageViewModel();
|
||||
// User types a local URL — this sets the local override flag
|
||||
vm.SiteUrl = "https://contoso.sharepoint.com/sites/custom";
|
||||
|
||||
// Act: global sites message arrives
|
||||
var vm = new TestFeatureViewModel(NullLogger<FeatureViewModelBase>.Instance);
|
||||
var sites = TwoSites();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
Assert.Equal(2, vm.TestGlobalSites.Count);
|
||||
|
||||
// Assert: local override takes precedence
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/custom", vm.SiteUrl);
|
||||
}
|
||||
|
||||
// ── Test 5: StorageViewModel clearing SiteUrl reverts to global ──────────
|
||||
|
||||
[Fact]
|
||||
public void OnSiteUrlChanged_WhenClearedAfterLocalOverride_RevertsToGlobalSite()
|
||||
{
|
||||
// Arrange: establish global sites first, then set a local override
|
||||
var vm = CreateStorageViewModel();
|
||||
var sites = TwoSites();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
// Confirm global was applied
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SiteUrl);
|
||||
|
||||
// User types their own URL — sets local override
|
||||
vm.SiteUrl = "https://contoso.sharepoint.com/sites/custom";
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/custom", vm.SiteUrl);
|
||||
|
||||
// Act: user clears the SiteUrl field
|
||||
vm.SiteUrl = string.Empty;
|
||||
|
||||
// Assert: override is cleared and global site is re-applied
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SiteUrl);
|
||||
}
|
||||
|
||||
// ── Test 6: PermissionsViewModel pre-populates SelectedSites from global ─
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_WithSites_PrePopulatesSelectedSitesOnPermissionsTab()
|
||||
{
|
||||
// Arrange
|
||||
var vm = CreatePermissionsViewModel();
|
||||
var sites = TwoSites();
|
||||
|
||||
// Act
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, vm.SelectedSites.Count);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SelectedSites[0].Url);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/finance", vm.SelectedSites[1].Url);
|
||||
}
|
||||
|
||||
// ── Test 7: PermissionsViewModel local picker override prevents global ───
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_AfterLocalPickerOverride_DoesNotChangeSelectedSites()
|
||||
{
|
||||
// Arrange: add a site locally to simulate local site picker usage
|
||||
var vm = CreatePermissionsViewModel();
|
||||
|
||||
// Simulate what ExecuteOpenSitePicker does when user picks sites locally:
|
||||
// set _hasLocalSiteOverride = true (via reflection since it's private) and add a site.
|
||||
// We do this by using the TenantSwitchedMessage pattern to first let global sites
|
||||
// populate, then simulate a local override by directly reflecting the flag.
|
||||
var globalSites = TwoSites();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(globalSites));
|
||||
// Global applied — SelectedSites has 2 entries
|
||||
Assert.Equal(2, vm.SelectedSites.Count);
|
||||
|
||||
// Simulate local override: clear and add a local site, then set the override flag
|
||||
var localSite = new SiteInfo("https://contoso.sharepoint.com/sites/local", "Local");
|
||||
vm.SelectedSites.Clear();
|
||||
vm.SelectedSites.Add(localSite);
|
||||
|
||||
// Use reflection to set the private _hasLocalSiteOverride flag (same as site picker would)
|
||||
var field = typeof(PermissionsViewModel)
|
||||
.GetField("_hasLocalSiteOverride", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
field!.SetValue(vm, true);
|
||||
|
||||
// Act: new global sites arrive
|
||||
var newGlobalSites = new List<SiteInfo>
|
||||
{
|
||||
new("https://contoso.sharepoint.com/sites/new1", "New1")
|
||||
}.AsReadOnly();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(newGlobalSites));
|
||||
|
||||
// Assert: local override prevents global from replacing SelectedSites
|
||||
Assert.Single(vm.SelectedSites);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/local", vm.SelectedSites[0].Url);
|
||||
}
|
||||
|
||||
// ── Test 8: Tenant switch resets local override; new global sites are applied ─
|
||||
|
||||
[Fact]
|
||||
public void TenantSwitched_AfterLocalOverride_ResetsOverrideSoNewGlobalSitesAreApplied()
|
||||
{
|
||||
// Arrange: user has a local override (different from global)
|
||||
var vm = CreateStorageViewModel();
|
||||
var sites = TwoSites();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
// Confirm global was applied
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SiteUrl);
|
||||
|
||||
// User types a different URL — sets local override
|
||||
vm.SiteUrl = "https://contoso.sharepoint.com/sites/custom";
|
||||
|
||||
// New global sites message should NOT change SiteUrl because of local override
|
||||
var updatedGlobal = new List<SiteInfo>
|
||||
// Act: send new sites
|
||||
var newSites = new List<SiteInfo>
|
||||
{
|
||||
new("https://contoso.sharepoint.com/sites/marketing", "Marketing")
|
||||
}.AsReadOnly();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(updatedGlobal));
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/custom", vm.SiteUrl); // override still active
|
||||
|
||||
// Act: tenant switch clears the override
|
||||
var newProfile = new TenantProfile
|
||||
{
|
||||
Name = "NewTenant",
|
||||
TenantUrl = "https://newtenant.sharepoint.com",
|
||||
ClientId = "new-client-id"
|
||||
};
|
||||
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(newProfile));
|
||||
|
||||
// Send new global sites after tenant switch — override should be gone
|
||||
var newSites = new List<SiteInfo>
|
||||
{
|
||||
new("https://newtenant.sharepoint.com/sites/sales", "Sales")
|
||||
}.AsReadOnly();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(newSites));
|
||||
|
||||
// Assert: new global site is applied (override was reset by tenant switch)
|
||||
Assert.Equal("https://newtenant.sharepoint.com/sites/sales", vm.SiteUrl);
|
||||
// Assert: old sites replaced
|
||||
Assert.Single(vm.TestGlobalSites);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/marketing", vm.TestGlobalSites[0].Url);
|
||||
}
|
||||
|
||||
// ── Test 9: TransferViewModel pre-fills SourceSiteUrl from first global ──
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -44,9 +45,13 @@ public class PermissionsViewModelTests
|
||||
mockSessionManager.Object,
|
||||
new NullLogger<FeatureViewModelBase>());
|
||||
|
||||
// Set up two site URLs via SelectedSites
|
||||
vm.SelectedSites.Add(new SiteInfo("https://tenant1.sharepoint.com/sites/alpha", "Alpha"));
|
||||
vm.SelectedSites.Add(new SiteInfo("https://tenant1.sharepoint.com/sites/beta", "Beta"));
|
||||
// Set up two site URLs via global site selection
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo>
|
||||
{
|
||||
new("https://tenant1.sharepoint.com/sites/alpha", "Alpha"),
|
||||
new("https://tenant1.sharepoint.com/sites/beta", "Beta")
|
||||
}.AsReadOnly()));
|
||||
vm.SetCurrentProfile(new TenantProfile
|
||||
{
|
||||
Name = "Test",
|
||||
@@ -118,7 +123,8 @@ public class PermissionsViewModelTests
|
||||
var vm = CreateViewModelWithResults(entries);
|
||||
|
||||
// Simulate scan completing
|
||||
vm.SelectedSites.Add(new SiteInfo("https://test.sharepoint.com", "Test"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://test.sharepoint.com", "Test") }.AsReadOnly()));
|
||||
vm.SetCurrentProfile(new TenantProfile { Name = "Test", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" });
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
@@ -143,7 +149,8 @@ public class PermissionsViewModelTests
|
||||
};
|
||||
|
||||
var vm = CreateViewModelWithResults(entries);
|
||||
vm.SelectedSites.Add(new SiteInfo("https://test.sharepoint.com", "Test"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://test.sharepoint.com", "Test") }.AsReadOnly()));
|
||||
vm.SetCurrentProfile(new TenantProfile { Name = "Test", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" });
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
@@ -169,7 +176,8 @@ public class PermissionsViewModelTests
|
||||
};
|
||||
|
||||
var vm = CreateViewModelWithResults(entries);
|
||||
vm.SelectedSites.Add(new SiteInfo("https://s1", "S1"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://s1", "S1") }.AsReadOnly()));
|
||||
vm.SetCurrentProfile(new TenantProfile { Name = "Test", TenantUrl = "https://s1", ClientId = "cid" });
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
|
||||
@@ -122,13 +122,13 @@ public class StorageViewModelChartTests
|
||||
SetFileTypeMetrics(vm, metrics);
|
||||
|
||||
// Initially IsDonutChart=true => InnerRadius=50
|
||||
var pieBefore = vm.PieChartSeries.Cast<PieSeries<long>>().ToList();
|
||||
var pieBefore = vm.PieChartSeries.Cast<PieSeries<double>>().ToList();
|
||||
Assert.All(pieBefore, s => Assert.Equal(50, s.InnerRadius));
|
||||
|
||||
// Toggle to bar (not donut) => InnerRadius=0
|
||||
vm.IsDonutChart = false;
|
||||
|
||||
var pieAfter = vm.PieChartSeries.Cast<PieSeries<long>>().ToList();
|
||||
var pieAfter = vm.PieChartSeries.Cast<PieSeries<double>>().ToList();
|
||||
Assert.All(pieAfter, s => Assert.Equal(0, s.InnerRadius));
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,8 @@ public class UserAccessAuditViewModelTests
|
||||
var (vm, auditMock, _) = CreateViewModel();
|
||||
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
|
||||
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
@@ -115,7 +116,8 @@ public class UserAccessAuditViewModelTests
|
||||
var (vm, _, _) = CreateViewModel(entries);
|
||||
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
|
||||
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
@@ -137,7 +139,8 @@ public class UserAccessAuditViewModelTests
|
||||
var (vm, _, _) = CreateViewModel(entries);
|
||||
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
|
||||
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
@@ -156,7 +159,8 @@ public class UserAccessAuditViewModelTests
|
||||
|
||||
// Populate state
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
Assert.NotEmpty(vm.Results);
|
||||
Assert.NotEmpty(vm.SelectedUsers);
|
||||
@@ -173,54 +177,31 @@ public class UserAccessAuditViewModelTests
|
||||
// Assert: state cleared
|
||||
Assert.Empty(vm.Results);
|
||||
Assert.Empty(vm.SelectedUsers);
|
||||
Assert.Empty(vm.SelectedSites);
|
||||
Assert.Empty(vm.FilterText);
|
||||
}
|
||||
|
||||
// ── Test 5: GlobalSitesChanged updates SelectedSites ─────────────────────
|
||||
// ── Test 5: RunOperation uses GlobalSites directly ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_updates_selected_sites()
|
||||
public async Task RunOperation_fails_gracefully_without_global_sites()
|
||||
{
|
||||
var (vm, _, _) = CreateViewModel();
|
||||
var sites = new List<SiteInfo>
|
||||
{
|
||||
new("https://contoso.sharepoint.com/sites/hr", "HR"),
|
||||
new("https://contoso.sharepoint.com/sites/finance", "Finance")
|
||||
}.AsReadOnly();
|
||||
var (vm, auditMock, _) = CreateViewModel();
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
// Do NOT send GlobalSitesChangedMessage — no sites selected
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
Assert.Equal(2, vm.SelectedSites.Count);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SelectedSites[0].Url);
|
||||
}
|
||||
|
||||
// ── Test 6: GlobalSitesChanged skipped when override active ──────────────
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_skipped_when_override()
|
||||
{
|
||||
var (vm, _, _) = CreateViewModel();
|
||||
|
||||
// Add a local site and set the override flag via reflection
|
||||
var localSite = new SiteInfo("https://contoso.sharepoint.com/sites/local", "Local");
|
||||
vm.SelectedSites.Add(localSite);
|
||||
|
||||
var field = typeof(UserAccessAuditViewModel)
|
||||
.GetField("_hasLocalSiteOverride",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
field!.SetValue(vm, true);
|
||||
|
||||
// Act: send global sites message
|
||||
var sites = new List<SiteInfo>
|
||||
{
|
||||
new("https://contoso.sharepoint.com/sites/global1", "Global1")
|
||||
}.AsReadOnly();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
|
||||
// Assert: SelectedSites unchanged (override prevented update)
|
||||
Assert.Single(vm.SelectedSites);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/local", vm.SelectedSites[0].Url);
|
||||
// Should not call audit service — early return with status message
|
||||
auditMock.Verify(
|
||||
s => s.AuditUsersAsync(
|
||||
It.IsAny<ISessionManager>(),
|
||||
It.IsAny<TenantProfile>(),
|
||||
It.IsAny<IReadOnlyList<string>>(),
|
||||
It.IsAny<IReadOnlyList<SiteInfo>>(),
|
||||
It.IsAny<ScanOptions>(),
|
||||
It.IsAny<IProgress<OperationProgress>>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
// ── Test 7: CanExport false when no results ───────────────────────────────
|
||||
@@ -245,7 +226,8 @@ public class UserAccessAuditViewModelTests
|
||||
var (vm, _, _) = CreateViewModel(entries);
|
||||
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
|
||||
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user