test(07-08): add export and ViewModel unit tests

- UserAccessCsvExportServiceTests (5): summary section, data header, RFC 4180
  quote escaping, 7-column count, WriteSingleFileAsync multi-user output
- UserAccessHtmlExportServiceTests (7): DOCTYPE, stats cards, dual-view sections,
  access type badges, filterTable JS, toggleView JS, HTML entity encoding
- UserAccessAuditViewModelTests (8): AuditUsersAsync invocation, results population,
  summary properties computation, tenant switch reset, GlobalSitesChanged update,
  override guard, CanExport false/true states
This commit is contained in:
Dev
2026-04-07 12:58:58 +02:00
parent 5df95032ee
commit 35b2c2a109
3 changed files with 531 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.Tests.Services.Export;
/// <summary>
/// Unit tests for UserAccessCsvExportService (Phase 7 Plan 08).
/// Verifies: summary section, column count, RFC 4180 escaping, per-user content.
/// </summary>
public class UserAccessCsvExportServiceTests
{
// ── Helper factory ────────────────────────────────────────────────────────
private static UserAccessEntry MakeEntry(
string userDisplay = "Alice Smith",
string userLogin = "alice@contoso.com",
string siteUrl = "https://contoso.sharepoint.com",
string siteTitle = "Contoso",
string objectType = "List",
string objectTitle = "Docs",
string objectUrl = "https://contoso.sharepoint.com/Docs",
string permLevel = "Read",
AccessType accessType = AccessType.Direct,
string grantedThrough = "Direct Permissions",
bool isHighPrivilege = false,
bool isExternal = false) =>
new(userDisplay, userLogin, siteUrl, siteTitle, objectType, objectTitle, objectUrl,
permLevel, accessType, grantedThrough, isHighPrivilege, isExternal);
private static readonly UserAccessEntry DefaultEntry = MakeEntry();
// ── Test 1: BuildCsv includes summary section ─────────────────────────────
[Fact]
public void BuildCsv_includes_summary_section()
{
var svc = new UserAccessCsvExportService();
var csv = svc.BuildCsv("Alice Smith", "alice@contoso.com", new[] { DefaultEntry });
Assert.Contains("User Access Audit Report", csv);
Assert.Contains("Alice Smith", csv);
Assert.Contains("alice@contoso.com", csv);
Assert.Contains("Total Accesses", csv);
Assert.Contains("Sites", csv);
}
// ── Test 2: BuildCsv includes data header line ────────────────────────────
[Fact]
public void BuildCsv_includes_data_header()
{
var svc = new UserAccessCsvExportService();
var csv = svc.BuildCsv("Alice Smith", "alice@contoso.com", new[] { DefaultEntry });
Assert.Contains("Site", csv);
Assert.Contains("Object Type", csv);
Assert.Contains("Object", csv);
Assert.Contains("Permission Level", csv);
Assert.Contains("Access Type", csv);
Assert.Contains("Granted Through", csv);
}
// ── Test 3: BuildCsv escapes double quotes (RFC 4180) ─────────────────────
[Fact]
public void BuildCsv_escapes_quotes()
{
var entryWithQuotes = MakeEntry(objectTitle: "Document \"Template\" Library");
var svc = new UserAccessCsvExportService();
var csv = svc.BuildCsv("Alice", "alice@contoso.com", new[] { entryWithQuotes });
// RFC 4180: double quotes inside a quoted field are doubled
Assert.Contains("\"\"Template\"\"", csv);
}
// ── Test 4: BuildCsv data rows have correct column count ──────────────────
[Fact]
public void BuildCsv_correct_column_count()
{
var svc = new UserAccessCsvExportService();
var csv = svc.BuildCsv("Alice", "alice@contoso.com", new[] { DefaultEntry });
// Find the header row and count its quoted comma-separated fields
// Header is: "Site","Object Type","Object","URL","Permission Level","Access Type","Granted Through"
// That is 7 fields.
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
// Find a data row (after the blank line separating summary from data)
// Data rows contain the entry content (not the header line itself)
// We want to count fields in the header row:
var headerLine = lines.FirstOrDefault(l => l.Contains("\"Site\",\"Object Type\""));
Assert.NotNull(headerLine);
// Count comma-separated quoted fields: split by "," boundary
var fields = CountCsvFields(headerLine!);
Assert.Equal(7, fields);
}
// ── Test 5: WriteSingleFileAsync includes entries for all users ───────────
[Fact]
public async Task WriteSingleFileAsync_includes_all_users()
{
var alice = MakeEntry(userDisplay: "Alice", userLogin: "alice@contoso.com");
var bob = MakeEntry(userDisplay: "Bob", userLogin: "bob@contoso.com");
var svc = new UserAccessCsvExportService();
var tmpFile = Path.GetTempFileName();
try
{
await svc.WriteSingleFileAsync(new[] { alice, bob }, tmpFile, CancellationToken.None);
var content = await File.ReadAllTextAsync(tmpFile);
Assert.Contains("alice@contoso.com", content);
Assert.Contains("bob@contoso.com", content);
Assert.Contains("Users Audited", content);
}
finally
{
File.Delete(tmpFile);
}
}
// ── Private helpers ───────────────────────────────────────────────────────
/// <summary>
/// Counts the number of comma-separated fields in a CSV line by stripping
/// surrounding quotes from each field.
/// </summary>
private static int CountCsvFields(string line)
{
// Simple RFC 4180 field counter — works for well-formed quoted fields
int count = 1;
bool inQuotes = false;
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (c == '"')
{
if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
i++; // skip escaped quote
else
inQuotes = !inQuotes;
}
else if (c == ',' && !inQuotes)
{
count++;
}
}
return count;
}
}

View File

@@ -0,0 +1,127 @@
using System.Collections.Generic;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.Tests.Services.Export;
/// <summary>
/// Unit tests for UserAccessHtmlExportService (Phase 7 Plan 08).
/// Verifies: DOCTYPE, stats cards, dual-view sections, access type badges,
/// filter script, toggle script, HTML entity encoding.
/// </summary>
public class UserAccessHtmlExportServiceTests
{
// ── Helper factory ────────────────────────────────────────────────────────
private static UserAccessEntry MakeEntry(
string userDisplay = "Alice Smith",
string userLogin = "alice@contoso.com",
string siteUrl = "https://contoso.sharepoint.com",
string siteTitle = "Contoso",
string objectType = "List",
string objectTitle = "Docs",
string objectUrl = "https://contoso.sharepoint.com/Docs",
string permLevel = "Read",
AccessType accessType = AccessType.Direct,
string grantedThrough = "Direct Permissions",
bool isHighPrivilege = false,
bool isExternal = false) =>
new(userDisplay, userLogin, siteUrl, siteTitle, objectType, objectTitle, objectUrl,
permLevel, accessType, grantedThrough, isHighPrivilege, isExternal);
private static readonly UserAccessEntry DefaultEntry = MakeEntry();
// ── Test 1: BuildHtml contains DOCTYPE ───────────────────────────────────
[Fact]
public void BuildHtml_contains_doctype()
{
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { DefaultEntry });
Assert.StartsWith("<!DOCTYPE html>", html.TrimStart());
}
// ── Test 2: BuildHtml has stats cards ─────────────────────────────────────
[Fact]
public void BuildHtml_has_stats_cards()
{
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { DefaultEntry });
Assert.Contains("Total Accesses", html);
Assert.Contains("stat-card", html);
}
// ── Test 3: BuildHtml has both view sections ──────────────────────────────
[Fact]
public void BuildHtml_has_both_views()
{
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { DefaultEntry });
// By-user view
Assert.Contains("view-user", html);
// By-site view
Assert.Contains("view-site", html);
}
// ── Test 4: BuildHtml has access type badge CSS classes ───────────────────
[Fact]
public void BuildHtml_has_access_type_badges()
{
var entries = new List<UserAccessEntry>
{
MakeEntry(accessType: AccessType.Direct),
MakeEntry(userLogin: "bob@contoso.com", accessType: AccessType.Group),
MakeEntry(userLogin: "carol@contoso.com", accessType: AccessType.Inherited)
};
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(entries);
Assert.Contains("access-direct", html);
Assert.Contains("access-group", html);
Assert.Contains("access-inherited", html);
}
// ── Test 5: BuildHtml has filterTable JS function ─────────────────────────
[Fact]
public void BuildHtml_has_filter_script()
{
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { DefaultEntry });
Assert.Contains("filterTable", html);
}
// ── Test 6: BuildHtml has toggleView JS function ──────────────────────────
[Fact]
public void BuildHtml_has_toggle_script()
{
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { DefaultEntry });
Assert.Contains("toggleView", html);
}
// ── Test 7: BuildHtml encodes HTML entities ───────────────────────────────
[Fact]
public void BuildHtml_encodes_html_entities()
{
var entryWithScript = MakeEntry(objectTitle: "<script>alert('xss')</script>");
var svc = new UserAccessHtmlExportService();
var html = svc.BuildHtml(new[] { entryWithScript });
// Raw script tag must not appear verbatim
Assert.DoesNotContain("<script>alert", html);
// Encoded form must be present
Assert.Contains("&lt;script&gt;", html);
}
}

View File

@@ -0,0 +1,246 @@
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 a mock IUserAccessAuditService.</summary>
private static (UserAccessAuditViewModel vm, Mock<IUserAccessAuditService> auditMock)
CreateViewModel(IReadOnlyList<UserAccessEntry>? auditResult = null)
{
var mockAudit = new Mock<IUserAccessAuditService>();
mockAudit
.Setup(s => s.AuditUsersAsync(
It.IsAny<ISessionManager>(),
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);
return (vm, mockAudit);
}
// ── Test 1: RunOperation calls AuditUsersAsync ────────────────────────────
[Fact]
public async Task RunOperation_calls_AuditUsersAsync()
{
var (vm, auditMock) = CreateViewModel();
vm.SelectedUsers.Add(MakeUser());
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
auditMock.Verify(
s => s.AuditUsersAsync(
It.IsAny<ISessionManager>(),
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());
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
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());
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
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());
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
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.SelectedSites);
Assert.Empty(vm.FilterText);
}
// ── Test 5: GlobalSitesChanged updates SelectedSites ─────────────────────
[Fact]
public void OnGlobalSitesChanged_updates_selected_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();
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
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);
}
// ── 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());
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
Assert.NotEmpty(vm.Results);
Assert.True(vm.ExportCsvCommand.CanExecute(null));
Assert.True(vm.ExportHtmlCommand.CanExecute(null));
}
}