test(07-08): add UserAccessAuditService unit tests
- 12 tests: user filtering, claim format matching, Direct/Group/Inherited access type classification, Full Control + SCA high-privilege detection, external user flagging (#EXT#), semicolon user/level splitting, multi-site scan
This commit is contained in:
390
SharepointToolbox.Tests/Services/UserAccessAuditServiceTests.cs
Normal file
390
SharepointToolbox.Tests/Services/UserAccessAuditServiceTests.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<IPermissionsService> permSvc, Mock<ISessionManager> sessionMgr)
|
||||||
|
CreateService(IReadOnlyList<PermissionEntry> entries)
|
||||||
|
{
|
||||||
|
var mockPerm = new Mock<IPermissionsService>();
|
||||||
|
mockPerm
|
||||||
|
.Setup(p => p.ScanSiteAsync(
|
||||||
|
It.IsAny<ClientContext>(),
|
||||||
|
It.IsAny<ScanOptions>(),
|
||||||
|
It.IsAny<IProgress<OperationProgress>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(entries);
|
||||||
|
|
||||||
|
var mockSession = new Mock<ISessionManager>();
|
||||||
|
mockSession
|
||||||
|
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
|
||||||
|
.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<PermissionEntry>
|
||||||
|
{
|
||||||
|
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<OperationProgress>(),
|
||||||
|
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<PermissionEntry>
|
||||||
|
{
|
||||||
|
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<OperationProgress>(),
|
||||||
|
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<PermissionEntry>
|
||||||
|
{
|
||||||
|
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<OperationProgress>(),
|
||||||
|
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<PermissionEntry>
|
||||||
|
{
|
||||||
|
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<OperationProgress>(),
|
||||||
|
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<PermissionEntry>
|
||||||
|
{
|
||||||
|
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<OperationProgress>(),
|
||||||
|
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<PermissionEntry>
|
||||||
|
{
|
||||||
|
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<OperationProgress>(),
|
||||||
|
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<PermissionEntry>
|
||||||
|
{
|
||||||
|
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<OperationProgress>(),
|
||||||
|
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<PermissionEntry>
|
||||||
|
{
|
||||||
|
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<OperationProgress>(),
|
||||||
|
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<PermissionEntry>
|
||||||
|
{
|
||||||
|
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<OperationProgress>(),
|
||||||
|
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<PermissionEntry>
|
||||||
|
{
|
||||||
|
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<OperationProgress>(),
|
||||||
|
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<PermissionEntry>
|
||||||
|
{
|
||||||
|
MakeEntry()
|
||||||
|
};
|
||||||
|
|
||||||
|
var (svc, _, session) = CreateService(entries);
|
||||||
|
|
||||||
|
var result = await svc.AuditUsersAsync(
|
||||||
|
session.Object,
|
||||||
|
Array.Empty<string>(),
|
||||||
|
new[] { MakeSite() },
|
||||||
|
DefaultOptions,
|
||||||
|
new Progress<OperationProgress>(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 12: Scans multiple sites ─────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Scans_multiple_sites()
|
||||||
|
{
|
||||||
|
var entries = new List<PermissionEntry>
|
||||||
|
{
|
||||||
|
MakeEntry(users: "Alice", logins: "alice@contoso.com")
|
||||||
|
};
|
||||||
|
|
||||||
|
var mockPerm = new Mock<IPermissionsService>();
|
||||||
|
mockPerm
|
||||||
|
.Setup(p => p.ScanSiteAsync(
|
||||||
|
It.IsAny<ClientContext>(),
|
||||||
|
It.IsAny<ScanOptions>(),
|
||||||
|
It.IsAny<IProgress<OperationProgress>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(entries);
|
||||||
|
|
||||||
|
var mockSession = new Mock<ISessionManager>();
|
||||||
|
mockSession
|
||||||
|
.Setup(s => s.GetOrCreateContextAsync(It.IsAny<TenantProfile>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((ClientContext)null!);
|
||||||
|
|
||||||
|
var svc = new UserAccessAuditService(mockPerm.Object);
|
||||||
|
|
||||||
|
var sites = new List<SiteInfo>
|
||||||
|
{
|
||||||
|
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<OperationProgress>(),
|
||||||
|
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<ClientContext>(),
|
||||||
|
It.IsAny<ScanOptions>(),
|
||||||
|
It.IsAny<IProgress<OperationProgress>>(),
|
||||||
|
It.IsAny<CancellationToken>()),
|
||||||
|
Times.Exactly(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user