diff --git a/SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs b/SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs new file mode 100644 index 0000000..498c68d --- /dev/null +++ b/SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs @@ -0,0 +1,167 @@ +using SharepointToolbox.Services; + +namespace SharepointToolbox.Tests.Services; + +/// +/// Unit tests for (Phase 17 Plan 01). +/// +/// Testing strategy: +/// SharePointGroupResolver wraps CSOM (ClientContext) and Microsoft Graph SDK. +/// Both require live infrastructure that cannot be mocked without heavy ceremony. +/// +/// We test what IS unit-testable without live infrastructure: +/// 1. IsAadGroup — static helper: login prefix pattern detection +/// 2. ExtractAadGroupId — static helper: GUID extraction from AAD group login +/// 3. StripClaims — static helper: UPN extraction after last pipe +/// 4. ResolveGroupsAsync with empty list — returns empty dict (no CSOM calls made) +/// +/// Integration tests requiring live tenant / CSOM context are skip-marked. +/// +[Trait("Category", "Unit")] +public class SharePointGroupResolverTests +{ + // ── IsAadGroup ───────────────────────────────────────────────────────────── + + [Fact] + public void IsAadGroup_AadGroupLogin_ReturnsTrue() + { + var login = "c:0t.c|tenant|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh"; + Assert.True(SharePointGroupResolver.IsAadGroup(login)); + } + + [Fact] + public void IsAadGroup_RegularUserLogin_ReturnsFalse() + { + var login = "i:0#.f|membership|user@contoso.com"; + Assert.False(SharePointGroupResolver.IsAadGroup(login)); + } + + [Fact] + public void IsAadGroup_SecurityGroupLogin_ReturnsFalse() + { + var login = "c:0(.s|true"; + Assert.False(SharePointGroupResolver.IsAadGroup(login)); + } + + [Fact] + public void IsAadGroup_EmptyString_ReturnsFalse() + { + Assert.False(SharePointGroupResolver.IsAadGroup(string.Empty)); + } + + [Fact] + public void IsAadGroup_CaseInsensitive_ReturnsTrue() + { + // Prefix check should be case-insensitive per OrdinalIgnoreCase + var login = "C:0T.C|TENANT|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh"; + Assert.True(SharePointGroupResolver.IsAadGroup(login)); + } + + // ── ExtractAadGroupId ────────────────────────────────────────────────────── + + [Fact] + public void ExtractAadGroupId_ValidAadLogin_ExtractsGuid() + { + var login = "c:0t.c|tenant|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh"; + var result = SharePointGroupResolver.ExtractAadGroupId(login); + Assert.Equal("aaaabbbb-cccc-dddd-eeee-ffffgggghhhh", result); + } + + [Fact] + public void ExtractAadGroupId_SingleSegment_ReturnsFullString() + { + // Edge: no pipe — LastIndexOf returns -1 so [(-1+1)..] = [0..] = whole string + var login = "nopipe"; + var result = SharePointGroupResolver.ExtractAadGroupId(login); + Assert.Equal("nopipe", result); + } + + // ── StripClaims ──────────────────────────────────────────────────────────── + + [Fact] + public void StripClaims_MembershipLogin_ReturnsUpn() + { + var login = "i:0#.f|membership|user@contoso.com"; + var result = SharePointGroupResolver.StripClaims(login); + Assert.Equal("user@contoso.com", result); + } + + [Fact] + public void StripClaims_NoClaimsPrefix_ReturnsFullString() + { + var login = "user@contoso.com"; + var result = SharePointGroupResolver.StripClaims(login); + Assert.Equal("user@contoso.com", result); + } + + [Fact] + public void StripClaims_MultiPipeLogin_ReturnsAfterLastPipe() + { + var login = "c:0t.c|tenant|some-guid-here"; + var result = SharePointGroupResolver.StripClaims(login); + Assert.Equal("some-guid-here", result); + } + + // ── ResolveGroupsAsync — empty input ────────────────────────────────────── + + [Fact] + public async Task ResolveGroupsAsync_EmptyGroupNames_ReturnsEmptyDict() + { + // Arrange: create resolver without real dependencies — empty list triggers early return + // No CSOM ClientContext or GraphClientFactory is called for empty input + // We pass null! for the factory since it must not be invoked for an empty list + var resolver = new SharePointGroupResolver(null!); + + // Act + var result = await resolver.ResolveGroupsAsync( + ctx: null!, + clientId: "ignored", + groupNames: Array.Empty(), + ct: CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task ResolveGroupsAsync_EmptyGroupNames_DictUsesOrdinalIgnoreCase() + { + // Arrange + var resolver = new SharePointGroupResolver(null!); + + // Act + var result = await resolver.ResolveGroupsAsync( + ctx: null!, + clientId: "ignored", + groupNames: Array.Empty(), + ct: CancellationToken.None); + + // Assert: verify the returned dict is OrdinalIgnoreCase by inserting a value + // and looking it up with different casing — this validates the comparer used + // at construction time even on an empty dict + var mutable = new Dictionary(result.Comparer) + { + ["Site Members"] = 1 + }; + Assert.True(mutable.ContainsKey("site members"), + "Result dictionary comparer must be OrdinalIgnoreCase"); + } + + // ── Integration tests (live SP tenant required) ──────────────────────────── + + [Fact(Skip = "Requires live SP tenant — run manually against a real ClientContext")] + public async Task ResolveGroupsAsync_KnownGroup_ReturnsMembers() + { + // Integration test: create a real ClientContext, call with a known group name, + // verify the returned list contains at least one ResolvedMember. + await Task.CompletedTask; + } + + [Fact(Skip = "Requires live SP tenant — verify case-insensitive lookup with real data")] + public async Task ResolveGroupsAsync_LookupDifferentCasing_FindsGroup() + { + // Integration test: resolver stores "Site Members" — lookup "site members" should succeed. + await Task.CompletedTask; + } +}