diff --git a/SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs b/SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs new file mode 100644 index 0000000..4ee8e10 --- /dev/null +++ b/SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs @@ -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; + +/// +/// Unit tests for UserAccessCsvExportService (Phase 7 Plan 08). +/// Verifies: summary section, column count, RFC 4180 escaping, per-user content. +/// +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 ─────────────────────────────────────────────────────── + + /// + /// Counts the number of comma-separated fields in a CSV line by stripping + /// surrounding quotes from each field. + /// + 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; + } +} diff --git a/SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs b/SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs new file mode 100644 index 0000000..7ed0355 --- /dev/null +++ b/SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services.Export; + +namespace SharepointToolbox.Tests.Services.Export; + +/// +/// 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. +/// +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("", 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 + { + 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: ""); + var svc = new UserAccessHtmlExportService(); + var html = svc.BuildHtml(new[] { entryWithScript }); + + // Raw script tag must not appear verbatim + Assert.DoesNotContain("