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>
284 lines
11 KiB
C#
284 lines
11 KiB
C#
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using CommunityToolkit.Mvvm.Messaging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
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>
|
|
/// Unit tests for UserAccessAuditViewModel (Phase 7 Plan 08).
|
|
/// Verifies: AuditUsersAsync invocation, results population, summary properties,
|
|
/// tenant switch reset, global sites message, override guard, CanExport state.
|
|
/// </summary>
|
|
public class UserAccessAuditViewModelTests
|
|
{
|
|
// ── Reset messenger between tests ─────────────────────────────────────────
|
|
|
|
public UserAccessAuditViewModelTests()
|
|
{
|
|
WeakReferenceMessenger.Default.Reset();
|
|
}
|
|
|
|
// ── Helper factories ──────────────────────────────────────────────────────
|
|
|
|
private static UserAccessEntry MakeEntry(
|
|
string userLogin = "alice@contoso.com",
|
|
string siteUrl = "https://contoso.sharepoint.com",
|
|
bool isHighPrivilege = false) =>
|
|
new("Alice", userLogin, siteUrl, "Contoso", "List", "Docs",
|
|
siteUrl + "/Docs", "Read", AccessType.Direct, "Direct Permissions",
|
|
isHighPrivilege, false);
|
|
|
|
private static GraphUserResult MakeUser(
|
|
string display = "Alice Smith",
|
|
string upn = "alice@contoso.com") =>
|
|
new(display, upn, upn);
|
|
|
|
/// <summary>Creates a ViewModel wired with mock services.</summary>
|
|
private static (UserAccessAuditViewModel vm, Mock<IUserAccessAuditService> auditMock, Mock<IGraphUserSearchService> graphMock)
|
|
CreateViewModel(IReadOnlyList<UserAccessEntry>? auditResult = null)
|
|
{
|
|
var mockAudit = new Mock<IUserAccessAuditService>();
|
|
mockAudit
|
|
.Setup(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>()))
|
|
.ReturnsAsync(auditResult ?? Array.Empty<UserAccessEntry>());
|
|
|
|
var mockGraph = new Mock<IGraphUserSearchService>();
|
|
var mockSession = new Mock<ISessionManager>();
|
|
|
|
var vm = new UserAccessAuditViewModel(
|
|
mockAudit.Object,
|
|
mockGraph.Object,
|
|
mockSession.Object,
|
|
NullLogger<FeatureViewModelBase>.Instance);
|
|
|
|
// Set a default profile so RunOperationAsync doesn't early-return
|
|
vm._currentProfile = new TenantProfile
|
|
{
|
|
Name = "Test",
|
|
TenantUrl = "https://contoso.sharepoint.com",
|
|
ClientId = "test-client-id"
|
|
};
|
|
|
|
return (vm, mockAudit, mockGraph);
|
|
}
|
|
|
|
// ── Test 1: RunOperation calls AuditUsersAsync ────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task RunOperation_calls_AuditUsersAsync()
|
|
{
|
|
var (vm, auditMock, _) = CreateViewModel();
|
|
|
|
vm.SelectedUsers.Add(MakeUser());
|
|
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
|
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
|
|
|
|
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
|
|
|
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.Once);
|
|
}
|
|
|
|
// ── Test 2: RunOperation populates Results ────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task RunOperation_populates_results()
|
|
{
|
|
var entries = new List<UserAccessEntry>
|
|
{
|
|
MakeEntry(),
|
|
MakeEntry(userLogin: "bob@contoso.com")
|
|
};
|
|
|
|
var (vm, _, _) = CreateViewModel(entries);
|
|
|
|
vm.SelectedUsers.Add(MakeUser());
|
|
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
|
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
|
|
|
|
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
|
|
|
Assert.Equal(2, vm.Results.Count);
|
|
}
|
|
|
|
// ── Test 3: RunOperation updates summary properties ───────────────────────
|
|
|
|
[Fact]
|
|
public async Task RunOperation_updates_summary_properties()
|
|
{
|
|
var entries = new List<UserAccessEntry>
|
|
{
|
|
MakeEntry(userLogin: "alice@contoso.com", siteUrl: "https://contoso.sharepoint.com/sites/s1", isHighPrivilege: true),
|
|
MakeEntry(userLogin: "alice@contoso.com", siteUrl: "https://contoso.sharepoint.com/sites/s2", isHighPrivilege: false),
|
|
MakeEntry(userLogin: "bob@contoso.com", siteUrl: "https://contoso.sharepoint.com/sites/s1", isHighPrivilege: true)
|
|
};
|
|
|
|
var (vm, _, _) = CreateViewModel(entries);
|
|
|
|
vm.SelectedUsers.Add(MakeUser());
|
|
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
|
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
|
|
|
|
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
|
|
|
Assert.Equal(3, vm.TotalAccessCount);
|
|
Assert.Equal(2, vm.SitesCount);
|
|
Assert.Equal(2, vm.HighPrivilegeCount);
|
|
}
|
|
|
|
// ── Test 4: OnTenantSwitched resets state ─────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task OnTenantSwitched_resets_state()
|
|
{
|
|
var entries = new List<UserAccessEntry> { MakeEntry() };
|
|
var (vm, _, _) = CreateViewModel(entries);
|
|
|
|
// Populate state
|
|
vm.SelectedUsers.Add(MakeUser());
|
|
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);
|
|
|
|
// Act: send TenantSwitchedMessage
|
|
var newProfile = new TenantProfile
|
|
{
|
|
Name = "NewTenant",
|
|
TenantUrl = "https://newtenant.sharepoint.com",
|
|
ClientId = "new-client-id"
|
|
};
|
|
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(newProfile));
|
|
|
|
// Assert: state cleared
|
|
Assert.Empty(vm.Results);
|
|
Assert.Empty(vm.SelectedUsers);
|
|
Assert.Empty(vm.FilterText);
|
|
}
|
|
|
|
// ── Test 5: RunOperation uses GlobalSites directly ─────────────────────
|
|
|
|
[Fact]
|
|
public async Task RunOperation_fails_gracefully_without_global_sites()
|
|
{
|
|
var (vm, auditMock, _) = CreateViewModel();
|
|
vm.SelectedUsers.Add(MakeUser());
|
|
// Do NOT send GlobalSitesChangedMessage — no sites selected
|
|
|
|
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
|
|
|
// 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 ───────────────────────────────
|
|
|
|
[Fact]
|
|
public void CanExport_false_when_no_results()
|
|
{
|
|
var (vm, _, _) = CreateViewModel();
|
|
|
|
// Results is empty by default
|
|
Assert.Empty(vm.Results);
|
|
Assert.False(vm.ExportCsvCommand.CanExecute(null));
|
|
Assert.False(vm.ExportHtmlCommand.CanExecute(null));
|
|
}
|
|
|
|
// ── Test 8: CanExport true when has results ───────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task CanExport_true_when_has_results()
|
|
{
|
|
var entries = new List<UserAccessEntry> { MakeEntry() };
|
|
var (vm, _, _) = CreateViewModel(entries);
|
|
|
|
vm.SelectedUsers.Add(MakeUser());
|
|
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.True(vm.ExportCsvCommand.CanExecute(null));
|
|
Assert.True(vm.ExportHtmlCommand.CanExecute(null));
|
|
}
|
|
|
|
// ── Test 9: Debounced search triggers SearchUsersAsync ───────────────────
|
|
|
|
[Fact]
|
|
public async Task SearchQuery_debounced_calls_SearchUsersAsync()
|
|
{
|
|
var graphResults = new List<GraphUserResult>
|
|
{
|
|
new("Alice Smith", "alice@contoso.com", "alice@contoso.com")
|
|
};
|
|
|
|
var (vm, _, graphMock) = CreateViewModel();
|
|
|
|
graphMock
|
|
.Setup(s => s.SearchUsersAsync(
|
|
It.IsAny<string>(),
|
|
It.Is<string>(q => q == "Ali"),
|
|
It.IsAny<int>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(graphResults);
|
|
|
|
// Set a TenantProfile so _currentProfile is non-null
|
|
var profile = new TenantProfile
|
|
{
|
|
Name = "Test",
|
|
TenantUrl = "https://contoso.sharepoint.com",
|
|
ClientId = "test-client-id"
|
|
};
|
|
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(profile));
|
|
|
|
// Act: set SearchQuery which triggers OnSearchQueryChanged → DebounceSearchAsync
|
|
vm.SearchQuery = "Ali";
|
|
|
|
// Wait longer than 300ms debounce to allow async fire-and-forget to complete
|
|
await Task.Delay(600);
|
|
|
|
// Assert: SearchUsersAsync was called with the query
|
|
graphMock.Verify(
|
|
s => s.SearchUsersAsync(
|
|
It.IsAny<string>(),
|
|
"Ali",
|
|
It.IsAny<int>(),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
}
|
|
}
|