# 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>
## 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 |
</phase_requirements>

---

## 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<PermissionEntry>` — 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: <name>"
    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<IReadOnlyList<PermissionEntry>> ScanSiteAsync(
        ClientContext ctx,
        ScanOptions options,
        IProgress<OperationProgress> 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<PermissionEntry>` during scan, assign as `new ObservableCollection<PermissionEntry>(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 `<controls:FeatureTabBase />` as a stub placeholder for the Permissions tab.
**How to avoid:** Replace that `<controls:FeatureTabBase />` with `<views:PermissionsView />` 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<string> 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 `<controls:FeatureTabBase />` 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)
