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)); + } +}