--- phase: 17-group-expansion-html-reports plan: 01 type: execute wave: 1 depends_on: [] files_modified: - SharepointToolbox/Core/Models/ResolvedMember.cs - SharepointToolbox/Services/ISharePointGroupResolver.cs - SharepointToolbox/Services/SharePointGroupResolver.cs - SharepointToolbox/App.xaml.cs - SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs autonomous: true requirements: [RPT-02] must_haves: truths: - "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|)" - "AAD groups detected in SharePoint group members are resolved transitively via Graph" - "ResolvedMember record exists in Core/Models with DisplayName and Login properties" artifacts: - path: "SharepointToolbox/Core/Models/ResolvedMember.cs" provides: "Value record for resolved group member" contains: "record ResolvedMember" - path: "SharepointToolbox/Services/ISharePointGroupResolver.cs" provides: "Interface contract for group resolution" exports: ["ISharePointGroupResolver"] - path: "SharepointToolbox/Services/SharePointGroupResolver.cs" provides: "CSOM + Graph implementation of group resolution" contains: "ResolveGroupsAsync" - path: "SharepointToolbox/App.xaml.cs" provides: "DI registration for ISharePointGroupResolver" contains: "ISharePointGroupResolver" - path: "SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs" provides: "Unit tests for resolver logic" contains: "IsAadGroup" key_links: - from: "SharepointToolbox/Services/SharePointGroupResolver.cs" to: "GraphClientFactory" via: "constructor injection" pattern: "GraphClientFactory" - from: "SharepointToolbox/Services/SharePointGroupResolver.cs" to: "ExecuteQueryRetryHelper" via: "CSOM retry" pattern: "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. @C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md @.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: ```csharp 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: ```csharp public class GraphClientFactory { public async Task CreateClientAsync(string clientId, CancellationToken ct) } ``` From SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs: ```csharp public static class ExecuteQueryRetryHelper { public static async Task ExecuteQueryRetryAsync( ClientContext ctx, IProgress? progress = null, CancellationToken ct = default) } ``` From SharepointToolbox/App.xaml.cs (DI registration pattern): ```csharp // Phase 4: Bulk Members services.AddTransient(); // Phase 7: User Access Audit services.AddTransient(); ``` 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>> ResolveGroupsAsync( ClientContext ctx, string clientId, IReadOnlyList 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()` - 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) - 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 After completion, create `.planning/phases/17-group-expansion-html-reports/17-01-SUMMARY.md`