- RPT-03-f: mergePermissions=false produces byte-identical output to default call - RPT-03-g: mergePermissions=true writes consolidated header and merged rows - Edge case: single-location entry has LocationCount=1 with no semicolons in Locations
275 lines
11 KiB
C#
275 lines
11 KiB
C#
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);
|
|
}
|
|
}
|
|
|
|
// ── 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 ───────────────────────────────────────────────────────
|
|
|
|
/// <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;
|
|
}
|
|
}
|