Files
Sharepoint-Toolbox/SharepointToolbox.Tests/Services/UserAccessAuditServiceTests.cs
Dev 5df95032ee 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
2026-04-07 12:57:21 +02:00

391 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}