From 031a7dbc0f40f2a41418267d8474d5847c870a49 Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 2 Apr 2026 13:25:38 +0200 Subject: [PATCH] docs(phase-02): research permissions phase domain Co-Authored-By: Claude Sonnet 4.6 --- .../phases/02-permissions/02-RESEARCH.md | 500 ++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 .planning/phases/02-permissions/02-RESEARCH.md diff --git a/.planning/phases/02-permissions/02-RESEARCH.md b/.planning/phases/02-permissions/02-RESEARCH.md new file mode 100644 index 0000000..c186ba8 --- /dev/null +++ b/.planning/phases/02-permissions/02-RESEARCH.md @@ -0,0 +1,500 @@ +# Phase 2: Permissions - Research + +**Researched:** 2026-04-02 +**Domain:** SharePoint CSOM/PnP.Framework permissions scanning, WPF DataGrid + ListView, CSV/HTML export +**Confidence:** HIGH + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| PERM-01 | User can scan permissions on a single SharePoint site with configurable depth | CSOM `Web.RoleAssignments`, `HasUniqueRoleAssignments` — depth controlled by folder-level filter; `PermissionsService.ScanSiteAsync` | +| PERM-02 | User can scan permissions across multiple selected sites in one operation | Site picker dialog (`SitePickerDialog`) calls `Get-PnPTenantSite` equivalent via CSOM `Tenant` API; loop calls `ScanSiteAsync` per URL | +| PERM-03 | Permissions scan includes owners, members, guests, external users, and broken inheritance | `Web.SiteUsers`, `SiteCollectionAdmin` flag, `RoleAssignment.Member.PrincipalType`, `IsGuestUser`, external = `#ext#` in LoginName | +| PERM-04 | User can choose to include or exclude inherited permissions | `HasUniqueRoleAssignments` guard already present in PS reference; ViewModel scan option `IncludeInherited` bool | +| PERM-05 | User can export permissions report to CSV (raw data) | `CsvExportService` using `System.Text` writer — no third-party library needed | +| PERM-06 | User can export permissions report to interactive HTML (sortable, filterable, groupable by user) | Self-contained HTML with vanilla JS — exact pattern ported from PS reference `Export-PermissionsToHTML` | +| PERM-07 | SharePoint 5,000-item list view threshold handled via pagination — no silent failures on large libraries | `SharePointPaginationHelper.GetAllItemsAsync` already built in Phase 1 — mandatory use | + + +--- + +## Summary + +Phase 2 builds the first real feature on top of the Phase 1 infrastructure. The technical domain is SharePoint CSOM permissions scanning via PnP.Framework 1.18.0 (already a project dependency), WPF UI for the Permissions tab, and file export (CSV + self-contained HTML). + +The reference PowerShell script (`Sharepoint_ToolBox.ps1`) contains a complete, working implementation of every piece needed: `Generate-PnPSitePermissionRpt` (scan engine), `Get-PnPPermissions` (per-object extractor), `Export-PermissionsToHTML` (HTML report), and `Merge-PermissionRows` (CSV merge). The C# port is primarily a faithful translation of that logic — not a design problem. + +The largest technical risk is the multi-site scan: the site picker requires calling the SharePoint Online Tenant API (`Microsoft.Online.SharePoint.TenantAdministration.Tenant`) via the `-admin` URL, which requires admin consent on the Azure app registration. The per-site scan (PERM-01) has no such dependency. The multi-site path (PERM-02) must connect to `https://tenant-admin.sharepoint.com` rather than the regular tenant URL. + +**Primary recommendation:** Port the PS reference logic directly into a `PermissionsService` class; use `SharePointPaginationHelper` for all folder enumeration; generate HTML as a string resource embedded in the assembly so no file-system template is needed. + +--- + +## Standard Stack + +### Core + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| PnP.Framework | 1.18.0 | CSOM wrapper — `ClientContext`, `Web`, `List`, `RoleAssignment` | Already in project; gives `ExecuteQueryAsync` and all SharePoint client objects | +| Microsoft.SharePoint.Client | (bundled with PnP.Framework) | CSOM types: `Web`, `List`, `ListItem`, `RoleAssignment`, `RoleDefinitionBindingCollection`, `PrincipalType` | The actual API surface for permissions | +| System.Text (built-in) | .NET 10 | CSV generation via `StringBuilder` | No dependency needed; CSV is flat text | +| CommunityToolkit.Mvvm | 8.4.2 | `[ObservableProperty]`, `AsyncRelayCommand`, `ObservableRecipient` | Already in project; all VMs use it | + +### Supporting + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| Microsoft.Win32.SaveFileDialog | (built-in WPF) | File save dialog for CSV/HTML export | When user clicks "Save Report" | +| System.Diagnostics.Process | (built-in) | Open exported file in browser/Excel | "Open Report" button | + +### Alternatives Considered + +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| String-built HTML export | RazorLight or T4 | Overkill for a single-template report; adds dependency; the PS reference proves a self-contained string approach is maintainable | +| CsvHelper for CSV | System.Text manual | CsvHelper is the standard but adds a NuGet dep; the PS reference `Export-Csv` proves the schema is simple enough for manual construction | + +**Installation:** No new packages required. All dependencies are already in `SharepointToolbox.csproj`. + +--- + +## Architecture Patterns + +### Recommended Project Structure + +``` +SharepointToolbox/ +├── Core/ +│ └── Models/ +│ ├── PermissionEntry.cs # Data model for one permission row +│ └── ScanOptions.cs # IncludeInherited, ScanFolders, FolderDepth, IncludeSubsites +├── Services/ +│ ├── PermissionsService.cs # Scan engine — calls CSOM, yields PermissionEntry +│ ├── SiteListService.cs # Loads tenant site list via Tenant admin API +│ └── Export/ +│ ├── CsvExportService.cs # Writes PermissionEntry[] → CSV file +│ └── HtmlExportService.cs # Writes PermissionEntry[] → self-contained HTML +├── ViewModels/ +│ └── Tabs/ +│ └── PermissionsViewModel.cs # FeatureViewModelBase subclass +└── Views/ + ├── Tabs/ + │ └── PermissionsView.xaml # Replaces FeatureTabBase stub in MainWindow + └── Dialogs/ + └── SitePickerDialog.xaml # Multi-site selection dialog +``` + +### Pattern 1: PermissionEntry data model + +**What:** A flat record that represents one permission assignment on one object (site, library, folder). Mirrors the PS `$entry` object exactly. + +**When to use:** All scan output is typed as `IReadOnlyList` — service produces it, export services consume it. + +```csharp +// Core/Models/PermissionEntry.cs +public record PermissionEntry( + string ObjectType, // "Site Collection" | "Site" | "List" | "Folder" + string Title, // Display name + string Url, // Direct link + bool HasUniquePermissions, + string Users, // Semicolon-joined display names + string UserLogins, // Semicolon-joined emails/login names + string PermissionLevels, // Semicolon-joined role names (excluding "Limited Access") + string GrantedThrough, // "Direct Permissions" | "SharePoint Group: " + string PrincipalType // "SharePointGroup" | "User" | "SharePointGroup" etc. +); +``` + +### Pattern 2: ScanOptions value object + +**What:** Immutable options passed to `PermissionsService`. Replaces the PS script globals. + +```csharp +// Core/Models/ScanOptions.cs +public record ScanOptions( + bool IncludeInherited = false, + bool ScanFolders = true, + int FolderDepth = 1, // 999 = unlimited (mirrors PS $PermFolderDepth) + bool IncludeSubsites = false +); +``` + +### Pattern 3: PermissionsService scan engine + +**What:** Async method that scans one `ClientContext` site and yields entries. Multi-site scanning is a loop in the ViewModel calling this per site. + +**When to use:** Called once per site URL. Callers pass the `ClientContext` from `SessionManager`. + +```csharp +// Services/PermissionsService.cs +public class PermissionsService +{ + // Returns all PermissionEntry rows for one site. + // Always uses SharePointPaginationHelper for folder enumeration. + public async Task> ScanSiteAsync( + ClientContext ctx, + ScanOptions options, + IProgress progress, + CancellationToken ct); +} +``` + +Internal structure mirrors the PS reference exactly: +1. Load site collection admins → emit one PermissionEntry with `ObjectType = "Site Collection"` +2. Call `GetWebPermissions(ctx.Web)` which calls `GetPermissionsForObject(web)` +3. `GetListPermissions(web)` — iterate non-hidden, non-system lists +4. If `ScanFolders`: call `GetFolderPermissions(list)` using `SharePointPaginationHelper.GetAllItemsAsync` +5. If `IncludeSubsites`: recurse into `web.Webs` + +### Pattern 4: CSOM load pattern for permissions + +**What:** The CSOM pattern for reading `RoleAssignments` requires explicit `ctx.Load` + `ExecuteQueryAsync` for each level. This is the exact translation of the PS `Get-PnPProperty` calls. + +```csharp +// Source: PnP.Framework CSOM patterns (verified against PS reference lines 1807-1848) +ctx.Load(obj, o => o.HasUniqueRoleAssignments, o => o.RoleAssignments.Include( + ra => ra.Member.Title, + ra => ra.Member.Email, + ra => ra.Member.LoginName, + ra => ra.Member.PrincipalType, + ra => ra.RoleDefinitionBindings.Include(rdb => rdb.Name) +)); +await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + +bool hasUnique = obj.HasUniqueRoleAssignments; +foreach (var ra in obj.RoleAssignments) +{ + // ra.Member.PrincipalType, ra.RoleDefinitionBindings are populated +} +``` + +**Critical:** Load the Include expression in ONE `ctx.Load` call rather than multiple round-trips. The PS script calls `Get-PnPProperty` multiple times (one per property) which is N+1. The C# version should batch into one load. + +### Pattern 5: SitePickerDialog (multi-site, PERM-02) + +**What:** A WPF `Window` with a `ListView` (checkboxes), filter textbox, "Load Sites", "Select All", "Deselect All", OK/Cancel. Mirrors the PS `Show-SitePicker` function. + +**Loading tenant sites:** Requires connecting to `https://{tenant}-admin.sharepoint.com` and calling: +```csharp +// Requires Microsoft.Online.SharePoint.TenantAdministration.Tenant — included in PnP.Framework +var tenantCtx = new ClientContext(adminUrl); +// Auth via SessionManager using admin URL +var tenant = new Tenant(tenantCtx); +var siteProps = tenant.GetSitePropertiesFromSharePoint("", true); +tenantCtx.Load(siteProps); +await tenantCtx.ExecuteQueryAsync(); +``` + +**Admin URL derivation:** `https://contoso.sharepoint.com` → `https://contoso-admin.sharepoint.com`. Pattern from PS line 333: replace `.sharepoint.com` with `-admin.sharepoint.com`. + +**IMPORTANT:** The user must have SharePoint admin rights for this to work. Auth uses the same `SessionManager.GetOrCreateContextAsync` with the admin URL (a different key from the regular tenant URL). + +### Pattern 6: HTML export — self-contained string + +**What:** The HTML report is generated as a C# string (embedded resource template or string builder), faithful port of `Export-PermissionsToHTML`. No file template on disk. + +**Key features to preserve from PS reference:** +- Stats cards: Total Entries, Unique Permission Sets, Distinct Users/Groups +- Filter input (vanilla JS `filterTable()`) +- Collapsible SharePoint Group member lists (`grp-tog`/`grp-members` CSS toggle) +- User pills with `data-email` for context menu (copy email, mailto) +- Type badges: color-coded for Site Collection / Site / List / Folder +- Unique vs Inherited badge per row + +The HTML template is ~200 lines of CSS + HTML + ~50 lines JS. Store as a `const string` in `HtmlExportService` or as an embedded `.html` resource file. + +### Pattern 7: CSV export — merge rows first + +**What:** Mirrors `Merge-PermissionRows` from PS: rows with identical `Users|PermissionLevels|GrantedThrough` are merged, collecting all their locations into a pipe-joined string. + +```csharp +// Services/Export/CsvExportService.cs +// CSV columns: Object, Title, URL, HasUniquePermissions, Users, UserLogins, Type, Permissions, GrantedThrough +// Merge before writing: group by (Users, PermissionLevels, GrantedThrough), join locations with " | " +``` + +### Anti-Patterns to Avoid + +- **Multiple `ExecuteQuery` calls per object:** Load `RoleAssignments` with full `Include()` in one round-trip, not sequential `Load`+`Execute` per property (the N+1 problem the PS script has). +- **Storing `ClientContext` in the ViewModel:** ViewModel calls `SessionManager.GetOrCreateContextAsync` at scan start, passes it to service, does not cache it. +- **Modifying `ObservableCollection` from background thread:** Accumulate in `List` during scan, assign as `new ObservableCollection(list)` via `Dispatcher.InvokeAsync` after completion. +- **Silent `Limited Access` inclusion:** Filter out `Limited Access` from `RoleDefinitionBindings` — PS reference line 1814 does this; C# port must too. +- **Scanning system lists:** Use the same `ExcludedLists` array from PS line 1914-1926. Failure to exclude them causes noise in output (App Packages, Workflow History, etc.). +- **Direct `ctx.ExecuteQueryAsync()` on folder lists:** MUST go through `SharePointPaginationHelper.GetAllItemsAsync`. Never raw enumerate a list. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| SharePoint 5,000-item pagination | Custom CAML loop | `SharePointPaginationHelper.GetAllItemsAsync` | Already built and tested in Phase 1 | +| Throttle retry | Custom retry loop | `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync` | Already built and tested in Phase 1 | +| Async command + progress + cancel | Custom ICommand | `FeatureViewModelBase` + `AsyncRelayCommand` | Pattern established in Phase 1 | +| CSV escaping | Manual replace | `string.Format` with double-quote wrapping + escape internal quotes | Standard CSV: `"value with ""quotes"""` | + +**Key insight:** The entire Phase 1 infrastructure was built specifically to be reused here. `PermissionsService` should be a pure service that takes a `ClientContext` and returns data — it never touches UI. The ViewModel handles threading. + +--- + +## Common Pitfalls + +### Pitfall 1: Tenant Admin URL for site listing + +**What goes wrong:** Connecting to `https://contoso.sharepoint.com` and calling the `Tenant` API returns "Access denied" or throws. +**Why it happens:** The `Tenant` class in `Microsoft.Online.SharePoint.TenantAdministration` requires connecting to the `-admin` URL. +**How to avoid:** Derive admin URL: `Regex.Replace(tenantUrl, @"(https://[^.]+)(\.sharepoint\.com.*)", "$1-admin$2")`. `SessionManager` treats the admin URL as a separate key — it will trigger a new interactive login if not already cached. +**Warning signs:** `ServerException: Access denied` or `401` on `Tenant.GetSitePropertiesFromSharePoint`. + +### Pitfall 2: `RoleAssignments` not loaded — empty collection silently + +**What goes wrong:** Iterating `obj.RoleAssignments` produces 0 items even though the site has permissions. +**Why it happens:** CSOM lazy loading — `RoleAssignments` is not populated unless explicitly loaded with `ctx.Load`. +**How to avoid:** Always use the batched `ctx.Load(obj, o => o.HasUniqueRoleAssignments, o => o.RoleAssignments.Include(...))` pattern before `ExecuteQueryAsync`. +**Warning signs:** Empty output for sites that definitely have permissions. + +### Pitfall 3: `SharingLinks` and system groups pollute output + +**What goes wrong:** The report shows `SharingLinks.{GUID}` entries or "Limited Access System Group" as users. +**Why it happens:** SharePoint creates these internal groups for link sharing. They appear as `SharePointGroup` principals. +**How to avoid:** Skip role assignments where `Member.LoginName` matches `^SharingLinks\.` or equals `Limited Access System Group`. PS reference line 1831. +**Warning signs:** Output contains rows with GUIDs in the Users column. + +### Pitfall 4: `Limited Access` permission level is noise + +**What goes wrong:** Users who only have "Limited Access" (implicit from accessing a subsite/item) appear as full permission entries. +**Why it happens:** SharePoint auto-grants "Limited Access" on parent objects when a user has explicit access to a child item. +**How to avoid:** After building `PermissionLevels` list from `RoleDefinitionBindings.Name`, filter out `"Limited Access"`. If the resulting list is empty, skip the entire row. PS reference lines 1813-1815. +**Warning signs:** Hundreds of extra rows with only "Limited Access" listed. + +### Pitfall 5: External user detection + +**What goes wrong:** External users are not separately classified; they appear as regular users. +**Why it happens:** SharePoint external users have `#EXT#` in their LoginName (e.g., `user_domain.com#EXT#@tenant.onmicrosoft.com`). PrincipalType is still `User`. +**How to avoid:** Check `loginName.Contains("#EXT#", StringComparison.OrdinalIgnoreCase)` to tag user as external. PERM-03 requires external users be identifiable — this is the detection mechanism. +**Warning signs:** PERM-03 acceptance test can't distinguish external from internal users. + +### Pitfall 6: Multi-site scan — wrong `ClientContext` per site + +**What goes wrong:** All sites scanned using the same `ClientContext` from the first site, so permissions returned are from the wrong site. +**Why it happens:** `ClientContext` is URL-specific. Reusing one context to query another site URL gives wrong or empty results. +**How to avoid:** Call `SessionManager.GetOrCreateContextAsync(profile with siteUrl)` for each site URL in the multi-site loop. Each site gets its own context from `SessionManager`'s cache. +**Warning signs:** All sites in multi-scan show identical permissions matching only the first site. + +### Pitfall 7: PermissionsView replaces the FeatureTabBase stub + +**What goes wrong:** Permissions tab still shows "Coming soon" after implementing the ViewModel. +**Why it happens:** `MainWindow.xaml` has `` as a stub placeholder for the Permissions tab. +**How to avoid:** Replace that `` with `` in MainWindow.xaml. Register `PermissionsViewModel` in DI. Wire DataContext in code-behind. +**Warning signs:** Running the app shows "Coming soon" on the Permissions tab. + +--- + +## Code Examples + +### CSOM load for permissions (batched, one round-trip per object) + +```csharp +// Source: PS reference lines 1807-1848, translated to CSOM Include() pattern +ctx.Load(web, + w => w.HasUniqueRoleAssignments, + w => w.RoleAssignments.Include( + ra => ra.Member.Title, + ra => ra.Member.Email, + ra => ra.Member.LoginName, + ra => ra.Member.PrincipalType, + ra => ra.RoleDefinitionBindings.Include(rdb => rdb.Name))); +await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); +``` + +### Admin URL derivation + +```csharp +// Source: PS reference line 333 +static string DeriveAdminUrl(string tenantUrl) +{ + // https://contoso.sharepoint.com → https://contoso-admin.sharepoint.com + return Regex.Replace( + tenantUrl.TrimEnd('/'), + @"(https://[^.]+)(\.sharepoint\.com)", + "$1-admin$2", + RegexOptions.IgnoreCase); +} +``` + +### External user detection + +```csharp +// Source: SharePoint Online behavior — external users always have #EXT# in LoginName +static bool IsExternalUser(string loginName) + => loginName.Contains("#EXT#", StringComparison.OrdinalIgnoreCase); +``` + +### System list exclusion list (port from PS reference line 1914) + +```csharp +// Source: PS reference lines 1914-1926 +private static readonly HashSet ExcludedLists = new(StringComparer.OrdinalIgnoreCase) +{ + "Access Requests", "App Packages", "appdata", "appfiles", "Apps in Testing", + "Cache Profiles", "Composed Looks", "Content and Structure Reports", + "Content type publishing error log", "Converted Forms", "Device Channels", + "Form Templates", "fpdatasources", "List Template Gallery", + "Long Running Operation Status", "Maintenance Log Library", "Images", + "site collection images", "Master Docs", "Master Page Gallery", "MicroFeed", + "NintexFormXml", "Quick Deploy Items", "Relationships List", "Reusable Content", + "Reporting Metadata", "Reporting Templates", "Search Config List", "Site Assets", + "Preservation Hold Library", "Site Pages", "Solution Gallery", "Style Library", + "Suggested Content Browser Locations", "Theme Gallery", "TaxonomyHiddenList", + "User Information List", "Web Part Gallery", "wfpub", "wfsvc", + "Workflow History", "Workflow Tasks", "Pages" +}; +``` + +### CSV row building (with proper escaping) + +```csharp +// Source: CSV RFC 4180 — enclose all fields in quotes, escape internal quotes by doubling +static string CsvField(string value) +{ + if (string.IsNullOrEmpty(value)) return "\"\""; + return $"\"{value.Replace("\"", "\"\"")}\""; +} +``` + +### Localization keys needed (new keys for Phase 2) + +Based on PS reference `Sharepoint_ToolBox.ps1` lines 2751-2761, these keys need adding to `Strings.resx`: + +``` +grp.scan.opts = "Scan Options" +chk.scan.folders = "Scan Folders" +chk.recursive = "Recursive (subsites)" +lbl.folder.depth = "Folder depth:" +chk.max.depth = "Maximum (all levels)" +chk.inherited.perms = "Include Inherited Permissions" +grp.export.fmt = "Export Format" +rad.csv.perms = "CSV" +rad.html.perms = "HTML" +btn.gen.perms = "Generate Report" +btn.open.perms = "Open Report" +btn.view.sites = "View Sites" +perm.site.url = "Site URL:" +perm.or.select = "or select multiple sites:" +perm.sites.selected = "{0} site(s) selected" +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| PnP.PowerShell `Get-PnPSite` / `Get-PnPProperty` | CSOM `ClientContext.Load` + `Include()` expressions | Always — C# uses CSOM directly | More efficient: one round-trip per object instead of N PnP cmdlet calls | +| PS `Export-Csv` (flat rows) | Merge rows by user+permission+grantedThrough, then export | Same as PS reference | Deduplicated report — one row per user/permission combination covering multiple locations | + +**No deprecated items:** PnP.Framework 1.18.0 (the project's chosen library) remains the current stable CSOM wrapper for .NET. The CSOM patterns used are long-stable. + +--- + +## Open Questions + +1. **Tenant admin consent for site listing (PERM-02)** + - What we know: The PS script uses `Get-PnPTenantSite` which requires the user to be a SharePoint admin and connects to `{tenant}-admin.sharepoint.com` + - What's unclear: The Azure app registration's required permissions. The PS script uses `-Interactive` login with the same `ClientId` — if the admin user consents during login, it works. The C# app uses the same interactive MSAL flow. + - Recommendation: Plan the SitePickerDialog to catch `ServerException` with "Access denied" and surface a clear message: "Site listing requires SharePoint administrator permissions. Connect with an admin account." Do not fail silently. + +2. **Guest user classification boundary** + - What we know: `#EXT#` in LoginName = external. `IsGuestUser` property exists on `User` object in CSOM but requires additional load. + - What's unclear: The exact PERM-03 acceptance criteria for "guests" — is it `#EXT#` detection sufficient, or does it require `User.IsGuestUser`? + - Recommendation: Use `#EXT#` detection as the primary external user flag (matches PS reference behavior). The `Type` field in `PermissionEntry` can carry `"External User"` when detected. Verify acceptance criteria during plan review. + +3. **WPF DataGrid vs ListView for results display** + - What we know: Phase 1 UI uses simple controls. Results can be large (thousands of rows). WPF `DataGrid` provides built-in column sorting; `ListView` with `GridView` is lighter-weight. + - What's unclear: Virtualization requirements — with 10,000+ rows, `DataGrid` needs `VirtualizingPanel.IsVirtualizing="True"` (which is default) and `EnableRowVirtualization="True"`. + - Recommendation: Use WPF `DataGrid` with `VirtualizingStackPanel` (default). It handles large result sets with virtualization enabled. Do not use a plain `ListBox` or `ListView`. + +--- + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | xUnit 2.9.3 | +| Config file | none — runner picks up via `xunit.runner.visualstudio` | +| Quick run command | `dotnet test SharepointToolbox.Tests\SharepointToolbox.Tests.csproj -x` | +| Full suite command | `dotnet test SharepointToolbox.slnx` | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| PERM-01 | `PermissionsService.ScanSiteAsync` returns entries for a mocked web | unit | `dotnet test --filter "FullyQualifiedName~PermissionsServiceTests"` | ❌ Wave 0 | +| PERM-02 | Multi-site loop in ViewModel calls service once per site URL | unit | `dotnet test --filter "FullyQualifiedName~PermissionsViewModelTests"` | ❌ Wave 0 | +| PERM-03 | External user detection: `#EXT#` in login name → classified correctly | unit | `dotnet test --filter "FullyQualifiedName~PermissionEntryClassificationTests"` | ❌ Wave 0 | +| PERM-04 | With `IncludeInherited=false`, items with `HasUniqueRoleAssignments=false` are skipped | unit | `dotnet test --filter "FullyQualifiedName~PermissionsServiceTests"` | ❌ Wave 0 | +| PERM-05 | `CsvExportService` produces correct CSV text for known input | unit | `dotnet test --filter "FullyQualifiedName~CsvExportServiceTests"` | ❌ Wave 0 | +| PERM-06 | `HtmlExportService` produces HTML containing expected user names | unit | `dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests"` | ❌ Wave 0 | +| PERM-07 | `SharePointPaginationHelper` already tested in Phase 1 — pagination used in folder scan | unit (existing) | `dotnet test --filter "FullyQualifiedName~SharePointPaginationHelperTests"` | ✅ (Phase 1) | + +**Note on CSOM service testing:** `PermissionsService` uses a live `ClientContext`. Unit tests should use an interface `IPermissionsService` with a mock for ViewModel tests. The concrete service itself is covered by the existing project convention of marking live-SharePoint tests as `[Trait("Category", "Integration")]` and `Skip`-ping them in the automated suite (same pattern as `GetOrCreateContextAsync_CreatesContext`). + +### Sampling Rate + +- **Per task commit:** `dotnet test SharepointToolbox.Tests\SharepointToolbox.Tests.csproj -x` +- **Per wave merge:** `dotnet test SharepointToolbox.slnx` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps + +- [ ] `SharepointToolbox.Tests/Services/PermissionsServiceTests.cs` — covers PERM-01, PERM-04 (via mock `ClientContext` wrapper interface) +- [ ] `SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs` — covers PERM-02 (multi-site loop) +- [ ] `SharepointToolbox.Tests/Services/PermissionEntryClassificationTests.cs` — covers PERM-03 (external user, principal type classification) +- [ ] `SharepointToolbox.Tests/Services/Export/CsvExportServiceTests.cs` — covers PERM-05 +- [ ] `SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs` — covers PERM-06 +- [ ] Interface `IPermissionsService` — needed for ViewModel mocking + +--- + +## Sources + +### Primary (HIGH confidence) + +- `Sharepoint_ToolBox.ps1` lines 1361-1989 — Complete working reference implementation of permissions scan, merge, CSV and HTML export +- `SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs` — Pagination helper already built in Phase 1, mandatory for PERM-07 +- `SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs` — Retry helper already built in Phase 1 +- `SharepointToolbox/ViewModels/FeatureViewModelBase.cs` — Base class all feature VMs extend +- `SharepointToolbox/Services/SessionManager.cs` — Single source of `ClientContext` objects +- `SharepointToolbox/SharepointToolbox.csproj` — Confirmed PnP.Framework 1.18.0, no new packages needed +- `SharepointToolbox/MainWindow.xaml` — Confirmed Permissions tab is currently `` stub +- `Sharepoint_ToolBox.ps1` lines 2751-2761 — All localization keys for Permissions tab UI controls + +### Secondary (MEDIUM confidence) + +- PS reference lines 333, 398, 1864 — Admin URL derivation pattern (`-admin.sharepoint.com` for `Tenant` API) +- PS reference lines 1914-1926 — System list exclusion list (verified complete set used in production) +- PS reference lines 1831 — SharingLinks group filtering (production-verified pattern) + +### Tertiary (LOW confidence) + +- `Microsoft.Online.SharePoint.TenantAdministration.Tenant` API availability in PnP.Framework 1.18.0 — assumed included based on PnP.Framework scope, not explicitly verified in package contents. If not available, fallback is the Microsoft Graph `sites` API which requires different auth scopes. + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — all packages already in project, no new deps needed +- Architecture: HIGH — PS reference is a complete working blueprint; translation is straightforward +- Pitfalls: HIGH — sourced directly from production PS code behavior and CSOM known patterns +- Tenant API (multi-site): MEDIUM — admin URL pattern confirmed from PS but `Tenant` class availability in the exact PnP.Framework version not inspected in nuget package manifest + +**Research date:** 2026-04-02 +**Valid until:** 2026-05-02 (PnP.Framework 1.18.0 is stable; no expected breaking changes in 30 days)