- 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
391 lines
14 KiB
C#
391 lines
14 KiB
C#
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));
|
||
}
|
||
}
|