Files
Sharepoint-Toolbox/.planning/phases/17-group-expansion-html-reports/17-01-PLAN.md
2026-04-09 12:59:12 +02:00

9.8 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
17-group-expansion-html-reports 01 execute 1
SharepointToolbox/Core/Models/ResolvedMember.cs
SharepointToolbox/Services/ISharePointGroupResolver.cs
SharepointToolbox/Services/SharePointGroupResolver.cs
SharepointToolbox/App.xaml.cs
SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs
true
RPT-02
truths artifacts key_links
SharePointGroupResolver returns a dictionary keyed by group name with OrdinalIgnoreCase comparison
SharePointGroupResolver returns empty list (never throws) when a group cannot be resolved
IsAadGroup correctly identifies AAD group login patterns (c:0t.c|tenant|<guid>)
AAD groups detected in SharePoint group members are resolved transitively via Graph
ResolvedMember record exists in Core/Models with DisplayName and Login properties
path provides contains
SharepointToolbox/Core/Models/ResolvedMember.cs Value record for resolved group member record ResolvedMember
path provides exports
SharepointToolbox/Services/ISharePointGroupResolver.cs Interface contract for group resolution
ISharePointGroupResolver
path provides contains
SharepointToolbox/Services/SharePointGroupResolver.cs CSOM + Graph implementation of group resolution ResolveGroupsAsync
path provides contains
SharepointToolbox/App.xaml.cs DI registration for ISharePointGroupResolver ISharePointGroupResolver
path provides contains
SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs Unit tests for resolver logic IsAadGroup
from to via pattern
SharepointToolbox/Services/SharePointGroupResolver.cs GraphClientFactory constructor injection GraphClientFactory
from to via pattern
SharepointToolbox/Services/SharePointGroupResolver.cs ExecuteQueryRetryHelper CSOM retry ExecuteQueryRetryAsync
Create the SharePoint group member resolution service that resolves group names to their transitive members via CSOM + Graph API.

Purpose: This service is the data provider for Phase 17 — it pre-resolves group members before HTML export so the export service remains pure and synchronous. Output: ResolvedMember model, ISharePointGroupResolver interface, SharePointGroupResolver implementation, DI registration, unit tests.

<execution_context> @C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/17-group-expansion-html-reports/17-RESEARCH.md

From SharepointToolbox/Core/Models/PermissionEntry.cs:

public record PermissionEntry(
    string ObjectType,
    string Title,
    string Url,
    bool HasUniquePermissions,
    string Users,
    string UserLogins,
    string PermissionLevels,
    string GrantedThrough,
    string PrincipalType  // "SharePointGroup" | "User" | "External User"
);

From SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs:

public class GraphClientFactory
{
    public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct)
}

From SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs:

public static class ExecuteQueryRetryHelper
{
    public static async Task ExecuteQueryRetryAsync(
        ClientContext ctx,
        IProgress<OperationProgress>? progress = null,
        CancellationToken ct = default)
}

From SharepointToolbox/App.xaml.cs (DI registration pattern):

// Phase 4: Bulk Members
services.AddTransient<IBulkMemberService, BulkMemberService>();
// Phase 7: User Access Audit
services.AddTransient<IGraphUserSearchService, GraphUserSearchService>();
Task 1: ResolvedMember model + ISharePointGroupResolver interface + SharePointGroupResolver implementation + tests SharepointToolbox/Core/Models/ResolvedMember.cs, SharepointToolbox/Services/ISharePointGroupResolver.cs, SharepointToolbox/Services/SharePointGroupResolver.cs, SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs - Test: IsAadGroup returns true for "c:0t.c|tenant|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh" and false for "i:0#.f|membership|user@contoso.com" and false for "c:0(.s|true" - Test: ExtractAadGroupId extracts GUID from "c:0t.c|tenant|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh" -> "aaaabbbb-cccc-dddd-eeee-ffffgggghhhh" - Test: StripClaims strips "i:0#.f|membership|user@contoso.com" -> "user@contoso.com" - Test: ResolveGroupsAsync returns empty dict when groupNames list is empty - Test: Result dictionary uses OrdinalIgnoreCase comparer (lookup "site members" matches key "Site Members") 1. Create `Core/Models/ResolvedMember.cs`: ```csharp namespace SharepointToolbox.Core.Models; public record ResolvedMember(string DisplayName, string Login); ```
2. Create `Services/ISharePointGroupResolver.cs`:
   ```csharp
   public interface ISharePointGroupResolver
   {
       Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupsAsync(
           ClientContext ctx, string clientId,
           IReadOnlyList<string> groupNames, CancellationToken ct);
   }
   ```

3. Create `Services/SharePointGroupResolver.cs` implementing `ISharePointGroupResolver`:
   - Constructor takes `GraphClientFactory` (same pattern as `BulkMemberService`)
   - `ResolveGroupsAsync` iterates group names (`.Distinct(StringComparer.OrdinalIgnoreCase)`)
   - Per group: CSOM `ctx.Web.SiteGroups.GetByName(name).Users` with `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct)`
   - Per user: check `IsAadGroup(loginName)` — if true, extract GUID via `ExtractAadGroupId` and call `ResolveAadGroupAsync` via Graph
   - `ResolveAadGroupAsync`: `graphClient.Groups[aadGroupId].TransitiveMembers.GraphUser.GetAsync()` with `PageIterator` for pagination — same pattern as `GraphUserDirectoryService`
   - De-duplicate members by Login (OrdinalIgnoreCase) using `.DistinctBy(m => m.Login, StringComparer.OrdinalIgnoreCase)`
   - Entire per-group block wrapped in try/catch — on any exception, log warning via `Serilog.Log.Warning` and set `result[groupName] = Array.Empty<ResolvedMember>()`
   - Result dictionary created with `StringComparer.OrdinalIgnoreCase`
   - Make `IsAadGroup`, `ExtractAadGroupId`, and `StripClaims` internal static (with `InternalsVisibleTo` already set for test project) so they are testable
   - `IsAadGroup` pattern: `login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase)`
   - `ExtractAadGroupId`: `login[(login.LastIndexOf('|') + 1)..]`
   - `StripClaims`: `login[(login.LastIndexOf('|') + 1)..]` (same substring after last pipe)

4. Create `SharePointGroupResolverTests.cs`:
   - Test `IsAadGroup` with true/false cases (see behavior above)
   - Test `ExtractAadGroupId` extraction
   - Test `StripClaims` extraction
   - Test `ResolveGroupsAsync` with empty list returns empty dict (needs mock ClientContext — use `[Fact(Skip="Requires CSOM ClientContext mock")]` if CSOM types cannot be easily mocked; alternatively test the static helpers only and add a skip-marked integration test)
   - Test case-insensitive dict: create resolver, call with known group, verify lookup with different casing works. If full resolution cannot be unit tested without live CSOM, mark as `[Fact(Skip="Requires live SP tenant")]` and focus unit tests on the three static helpers
dotnet test --filter "FullyQualifiedName~SharePointGroupResolverTests" --no-build ResolvedMember record exists, ISharePointGroupResolver interface defined, SharePointGroupResolver compiles with CSOM + Graph resolution, static helpers (IsAadGroup, ExtractAadGroupId, StripClaims) have green unit tests Task 2: DI registration in App.xaml.cs SharepointToolbox/App.xaml.cs Add DI registration for `ISharePointGroupResolver` in `App.xaml.cs` after the Phase 4 Bulk Members block (or near other service registrations): ```csharp // Phase 17: Group Expansion services.AddTransient(); ``` Add the `using SharepointToolbox.Services;` if not already present (it should be since `IPermissionsService` is already registered from the same namespace). dotnet build --no-restore 2>&1 | tail -5 ISharePointGroupResolver registered in DI container, solution builds with 0 errors - `dotnet build` — 0 errors, 0 warnings - `dotnet test --filter "FullyQualifiedName~SharePointGroupResolverTests"` — all tests pass - `dotnet test` — full suite green (no regressions)

<success_criteria>

  • ResolvedMember record exists at Core/Models/ResolvedMember.cs
  • ISharePointGroupResolver interface defines ResolveGroupsAsync contract
  • SharePointGroupResolver implements CSOM group user loading + Graph transitive resolution
  • Static helpers (IsAadGroup, ExtractAadGroupId, StripClaims) have passing unit tests
  • DI registration wired in App.xaml.cs
  • Full test suite green </success_criteria>
After completion, create `.planning/phases/17-group-expansion-html-reports/17-01-SUMMARY.md`