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); private static TenantProfile DefaultProfile => new() { Name = "Test", TenantUrl = "https://contoso.sharepoint.com", ClientId = "test-client-id" }; // ── 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, DefaultProfile, 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, DefaultProfile, 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, DefaultProfile, 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, DefaultProfile, 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, DefaultProfile, 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, DefaultProfile, 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, DefaultProfile, 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, DefaultProfile, 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, DefaultProfile, 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, DefaultProfile, 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, DefaultProfile, 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, DefaultProfile, 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)); } }