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("