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); } } // ── RPT-03-f: mergePermissions=false produces identical output to default ── [Fact] public async Task WriteSingleFileAsync_mergePermissionsfalse_produces_identical_output() { var alice1 = MakeEntry(userLogin: "alice@contoso.com", siteTitle: "Contoso", permLevel: "Read"); var alice2 = MakeEntry(userLogin: "alice@contoso.com", siteTitle: "Dev Site", permLevel: "Read", siteUrl: "https://contoso.sharepoint.com/sites/dev", objectUrl: "https://contoso.sharepoint.com/sites/dev/Docs"); var bob = MakeEntry(userDisplay: "Bob Smith", userLogin: "bob@contoso.com", permLevel: "Contribute"); var entries = new[] { alice1, alice2, bob }; var svc = new UserAccessCsvExportService(); var tmpDefault = Path.GetTempFileName(); var tmpExplicit = Path.GetTempFileName(); try { // Default call (no mergePermissions param) await svc.WriteSingleFileAsync(entries, tmpDefault, CancellationToken.None); // Explicit mergePermissions=false await svc.WriteSingleFileAsync(entries, tmpExplicit, CancellationToken.None, mergePermissions: false); var defaultContent = await File.ReadAllBytesAsync(tmpDefault); var explicitContent = await File.ReadAllBytesAsync(tmpExplicit); Assert.Equal(defaultContent, explicitContent); } finally { File.Delete(tmpDefault); File.Delete(tmpExplicit); } } // ── RPT-03-g: mergePermissions=true writes consolidated rows ────────────── [Fact] public async Task WriteSingleFileAsync_mergePermissionstrue_writes_consolidated_rows() { // alice has 2 entries with same key (same login, permLevel, accessType, grantedThrough) // they should be merged into 1 row with 2 locations var alice1 = MakeEntry( userDisplay: "Alice Smith", userLogin: "alice@contoso.com", siteUrl: "https://contoso.sharepoint.com", siteTitle: "Contoso", permLevel: "Read", accessType: AccessType.Direct, grantedThrough: "Direct Permissions"); var alice2 = MakeEntry( userDisplay: "Alice Smith", userLogin: "alice@contoso.com", siteUrl: "https://dev.sharepoint.com", siteTitle: "Dev Site", objectUrl: "https://dev.sharepoint.com/Docs", permLevel: "Read", accessType: AccessType.Direct, grantedThrough: "Direct Permissions"); // bob has a different key — separate row var bob = MakeEntry( userDisplay: "Bob Smith", userLogin: "bob@contoso.com", siteTitle: "Contoso", permLevel: "Contribute", accessType: AccessType.Direct, grantedThrough: "Direct Permissions"); var entries = new[] { alice1, alice2, bob }; var svc = new UserAccessCsvExportService(); var tmpFile = Path.GetTempFileName(); try { await svc.WriteSingleFileAsync(entries, tmpFile, CancellationToken.None, mergePermissions: true); var content = await File.ReadAllTextAsync(tmpFile); // Header must contain consolidated columns Assert.Contains("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\"", content); // Alice's two entries merged — locations column contains both site titles Assert.Contains("Contoso", content); Assert.Contains("Dev Site", content); // Bob appears as a separate row Assert.Contains("bob@contoso.com", content); // The consolidated report label should appear Assert.Contains("User Access Audit Report (Consolidated)", content); } finally { File.Delete(tmpFile); } } // ── RPT-03-g edge case: single-location consolidated entry ──────────────── [Fact] public async Task WriteSingleFileAsync_mergePermissionstrue_singleLocation_noSemicolon() { var entry = MakeEntry( userDisplay: "Alice Smith", userLogin: "alice@contoso.com", siteTitle: "Contoso", permLevel: "Read", accessType: AccessType.Direct, grantedThrough: "Direct Permissions"); var svc = new UserAccessCsvExportService(); var tmpFile = Path.GetTempFileName(); try { await svc.WriteSingleFileAsync(new[] { entry }, tmpFile, CancellationToken.None, mergePermissions: true); var content = await File.ReadAllTextAsync(tmpFile); // Should contain exactly "1" as LocationCount Assert.Contains("\"1\"", content); // Locations field for a single entry should not contain a semicolon // Find the data row for alice and verify no semicolon in Locations var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries); var dataRow = lines.FirstOrDefault(l => l.Contains("alice@contoso.com") && !l.StartsWith("\"Users")); Assert.NotNull(dataRow); // The Locations column value is "Contoso" with no semicolons Assert.DoesNotContain("Contoso; ", dataRow); } 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; } }