diff --git a/SharepointToolbox.Tests/Services/UserAccessAuditServiceTests.cs b/SharepointToolbox.Tests/Services/UserAccessAuditServiceTests.cs
new file mode 100644
index 0000000..c0a34e1
--- /dev/null
+++ b/SharepointToolbox.Tests/Services/UserAccessAuditServiceTests.cs
@@ -0,0 +1,390 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.SharePoint.Client;
+using Moq;
+using SharepointToolbox.Core.Models;
+using SharepointToolbox.Services;
+
+namespace SharepointToolbox.Tests.Services;
+
+///
+/// Unit tests for UserAccessAuditService (Phase 7 Plan 08).
+/// Verifies: user filtering, claim format matching, access type classification,
+/// high-privilege detection, external user flagging, semicolon splitting, multi-site scanning.
+///
+public class UserAccessAuditServiceTests
+{
+ // ── Helper factory for PermissionEntry ────────────────────────────────────
+
+ private static PermissionEntry MakeEntry(
+ string users = "Alice",
+ string logins = "alice@contoso.com",
+ string levels = "Read",
+ string grantedThrough = "Direct Permissions",
+ bool hasUnique = true,
+ string objectType = "List",
+ string title = "Docs",
+ string url = "https://contoso.sharepoint.com/Docs",
+ string principalType = "User") =>
+ new(objectType, title, url, hasUnique, users, logins, levels, grantedThrough, principalType);
+
+ private static SiteInfo MakeSite(string url = "https://contoso.sharepoint.com", string title = "Contoso") =>
+ new(url, title);
+
+ // ── Helper: create a configured service + mocks ───────────────────────────
+
+ private static (UserAccessAuditService svc, Mock permSvc, Mock sessionMgr)
+ CreateService(IReadOnlyList entries)
+ {
+ var mockPerm = new Mock();
+ mockPerm
+ .Setup(p => p.ScanSiteAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(entries);
+
+ var mockSession = new Mock();
+ mockSession
+ .Setup(s => s.GetOrCreateContextAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ClientContext)null!);
+
+ var svc = new UserAccessAuditService(mockPerm.Object);
+ return (svc, mockPerm, mockSession);
+ }
+
+ private static ScanOptions DefaultOptions => new(
+ IncludeInherited: false,
+ ScanFolders: false,
+ FolderDepth: 1,
+ IncludeSubsites: false);
+
+ // ── Test 1: Filter by target user login ───────────────────────────────────
+
+ [Fact]
+ public async Task Filters_by_target_user_login()
+ {
+ var entries = new List
+ {
+ MakeEntry(users: "Alice", logins: "alice@contoso.com"),
+ MakeEntry(users: "Bob", logins: "bob@contoso.com"),
+ MakeEntry(users: "Carol", logins: "carol@contoso.com")
+ };
+
+ var (svc, _, session) = CreateService(entries);
+
+ var result = await svc.AuditUsersAsync(
+ session.Object,
+ new[] { "alice@contoso.com" },
+ new[] { MakeSite() },
+ DefaultOptions,
+ new Progress(),
+ CancellationToken.None);
+
+ Assert.All(result, r => Assert.Equal("alice@contoso.com", r.UserLogin));
+ Assert.DoesNotContain(result, r => r.UserLogin == "bob@contoso.com");
+ Assert.DoesNotContain(result, r => r.UserLogin == "carol@contoso.com");
+ }
+
+ // ── Test 2: Claim format matching ─────────────────────────────────────────
+
+ [Fact]
+ public async Task Matches_user_by_email_in_claim_format()
+ {
+ var claimLogin = "i:0#.f|membership|alice@contoso.com";
+ var entries = new List
+ {
+ MakeEntry(users: "Alice", logins: claimLogin)
+ };
+
+ var (svc, _, session) = CreateService(entries);
+
+ var result = await svc.AuditUsersAsync(
+ session.Object,
+ new[] { "alice@contoso.com" },
+ new[] { MakeSite() },
+ DefaultOptions,
+ new Progress(),
+ CancellationToken.None);
+
+ Assert.Single(result);
+ Assert.Equal(claimLogin, result[0].UserLogin);
+ }
+
+ // ── Test 3: Classifies Direct access ─────────────────────────────────────
+
+ [Fact]
+ public async Task Classifies_direct_access()
+ {
+ var entries = new List
+ {
+ MakeEntry(hasUnique: true, grantedThrough: "Direct Permissions")
+ };
+
+ var (svc, _, session) = CreateService(entries);
+
+ var result = await svc.AuditUsersAsync(
+ session.Object,
+ new[] { "alice@contoso.com" },
+ new[] { MakeSite() },
+ DefaultOptions,
+ new Progress(),
+ CancellationToken.None);
+
+ Assert.Single(result);
+ Assert.Equal(AccessType.Direct, result[0].AccessType);
+ }
+
+ // ── Test 4: Classifies Group access ──────────────────────────────────────
+
+ [Fact]
+ public async Task Classifies_group_access()
+ {
+ var entries = new List
+ {
+ MakeEntry(hasUnique: true, grantedThrough: "SharePoint Group: Members")
+ };
+
+ var (svc, _, session) = CreateService(entries);
+
+ var result = await svc.AuditUsersAsync(
+ session.Object,
+ new[] { "alice@contoso.com" },
+ new[] { MakeSite() },
+ DefaultOptions,
+ new Progress(),
+ CancellationToken.None);
+
+ Assert.Single(result);
+ Assert.Equal(AccessType.Group, result[0].AccessType);
+ }
+
+ // ── Test 5: Classifies Inherited access ──────────────────────────────────
+
+ [Fact]
+ public async Task Classifies_inherited_access()
+ {
+ var entries = new List
+ {
+ MakeEntry(hasUnique: false, grantedThrough: "Direct Permissions")
+ };
+
+ var (svc, _, session) = CreateService(entries);
+
+ var result = await svc.AuditUsersAsync(
+ session.Object,
+ new[] { "alice@contoso.com" },
+ new[] { MakeSite() },
+ DefaultOptions,
+ new Progress(),
+ CancellationToken.None);
+
+ Assert.Single(result);
+ Assert.Equal(AccessType.Inherited, result[0].AccessType);
+ }
+
+ // ── Test 6: Detects high privilege (Full Control) ─────────────────────────
+
+ [Fact]
+ public async Task Detects_high_privilege()
+ {
+ var entries = new List
+ {
+ MakeEntry(levels: "Full Control")
+ };
+
+ var (svc, _, session) = CreateService(entries);
+
+ var result = await svc.AuditUsersAsync(
+ session.Object,
+ new[] { "alice@contoso.com" },
+ new[] { MakeSite() },
+ DefaultOptions,
+ new Progress(),
+ CancellationToken.None);
+
+ Assert.Single(result);
+ Assert.True(result[0].IsHighPrivilege);
+ }
+
+ // ── Test 7: Detects high privilege (Site Collection Administrator) ─────────
+
+ [Fact]
+ public async Task Detects_high_privilege_site_admin()
+ {
+ var entries = new List
+ {
+ MakeEntry(levels: "Site Collection Administrator")
+ };
+
+ var (svc, _, session) = CreateService(entries);
+
+ var result = await svc.AuditUsersAsync(
+ session.Object,
+ new[] { "alice@contoso.com" },
+ new[] { MakeSite() },
+ DefaultOptions,
+ new Progress(),
+ CancellationToken.None);
+
+ Assert.Single(result);
+ Assert.True(result[0].IsHighPrivilege);
+ }
+
+ // ── Test 8: Flags external user ───────────────────────────────────────────
+
+ [Fact]
+ public async Task Flags_external_user()
+ {
+ var extLogin = "alice_fabrikam.com#EXT#@contoso.onmicrosoft.com";
+ var entries = new List
+ {
+ MakeEntry(users: "Alice (External)", logins: extLogin)
+ };
+
+ var (svc, _, session) = CreateService(entries);
+
+ var result = await svc.AuditUsersAsync(
+ session.Object,
+ new[] { extLogin },
+ new[] { MakeSite() },
+ DefaultOptions,
+ new Progress(),
+ CancellationToken.None);
+
+ Assert.Single(result);
+ Assert.True(result[0].IsExternalUser);
+ }
+
+ // ── Test 9: Splits semicolon-joined users ─────────────────────────────────
+
+ [Fact]
+ public async Task Splits_semicolon_users()
+ {
+ var entries = new List
+ {
+ MakeEntry(users: "Alice;Bob", logins: "alice@x.com;bob@x.com", levels: "Read")
+ };
+
+ var (svc, _, session) = CreateService(entries);
+
+ var result = await svc.AuditUsersAsync(
+ session.Object,
+ new[] { "alice@x.com", "bob@x.com" },
+ new[] { MakeSite() },
+ DefaultOptions,
+ new Progress(),
+ CancellationToken.None);
+
+ // 2 users × 1 permission level = 2 rows
+ Assert.Equal(2, result.Count);
+ Assert.Contains(result, r => r.UserLogin == "alice@x.com");
+ Assert.Contains(result, r => r.UserLogin == "bob@x.com");
+ }
+
+ // ── Test 10: Splits semicolon permission levels ───────────────────────────
+
+ [Fact]
+ public async Task Splits_semicolon_permission_levels()
+ {
+ var entries = new List
+ {
+ MakeEntry(users: "Alice", logins: "alice@contoso.com", levels: "Read;Contribute")
+ };
+
+ var (svc, _, session) = CreateService(entries);
+
+ var result = await svc.AuditUsersAsync(
+ session.Object,
+ new[] { "alice@contoso.com" },
+ new[] { MakeSite() },
+ DefaultOptions,
+ new Progress(),
+ CancellationToken.None);
+
+ // 1 user × 2 permission levels = 2 rows
+ Assert.Equal(2, result.Count);
+ Assert.Contains(result, r => r.PermissionLevel == "Read");
+ Assert.Contains(result, r => r.PermissionLevel == "Contribute");
+ }
+
+ // ── Test 11: Empty targets returns empty ──────────────────────────────────
+
+ [Fact]
+ public async Task Empty_targets_returns_empty()
+ {
+ var entries = new List
+ {
+ MakeEntry()
+ };
+
+ var (svc, _, session) = CreateService(entries);
+
+ var result = await svc.AuditUsersAsync(
+ session.Object,
+ Array.Empty(),
+ new[] { MakeSite() },
+ DefaultOptions,
+ new Progress(),
+ CancellationToken.None);
+
+ Assert.Empty(result);
+ }
+
+ // ── Test 12: Scans multiple sites ─────────────────────────────────────────
+
+ [Fact]
+ public async Task Scans_multiple_sites()
+ {
+ var entries = new List
+ {
+ MakeEntry(users: "Alice", logins: "alice@contoso.com")
+ };
+
+ var mockPerm = new Mock();
+ mockPerm
+ .Setup(p => p.ScanSiteAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(entries);
+
+ var mockSession = new Mock();
+ mockSession
+ .Setup(s => s.GetOrCreateContextAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ClientContext)null!);
+
+ var svc = new UserAccessAuditService(mockPerm.Object);
+
+ var sites = new List
+ {
+ new("https://contoso.sharepoint.com/sites/site1", "Site 1"),
+ new("https://contoso.sharepoint.com/sites/site2", "Site 2")
+ };
+
+ var result = await svc.AuditUsersAsync(
+ mockSession.Object,
+ new[] { "alice@contoso.com" },
+ sites,
+ DefaultOptions,
+ new Progress(),
+ CancellationToken.None);
+
+ // Entries from both sites should appear
+ Assert.Equal(2, result.Count);
+ Assert.Contains(result, r => r.SiteUrl == "https://contoso.sharepoint.com/sites/site1");
+ Assert.Contains(result, r => r.SiteUrl == "https://contoso.sharepoint.com/sites/site2");
+
+ // ScanSiteAsync was called exactly twice (once per site)
+ mockPerm.Verify(
+ p => p.ScanSiteAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()),
+ Times.Exactly(2));
+ }
+}