diff --git a/.planning/phases/10-branding-data-foundation/10-RESEARCH.md b/.planning/phases/10-branding-data-foundation/10-RESEARCH.md new file mode 100644 index 0000000..5417ea2 --- /dev/null +++ b/.planning/phases/10-branding-data-foundation/10-RESEARCH.md @@ -0,0 +1,530 @@ +# Phase 10: Branding Data Foundation - Research + +**Researched:** 2026-04-08 +**Domain:** C# WPF / .NET 10 — JSON persistence, image validation, Microsoft Graph SDK pagination +**Confidence:** HIGH + +## Summary + +Phase 10 is a pure infrastructure phase: no UI, no new NuGet packages. It introduces three new models (`LogoData`, `BrandingSettings`, plus extends `TenantProfile`), two repositories (`BrandingRepository` mirroring `SettingsRepository`), two services (`BrandingService` for validation/compression, `GraphUserDirectoryService` for paginated Graph enumeration), and registration of those in `App.xaml.cs`. All work is additive — nothing in the existing stack is removed or renamed. + +The central technical challenge splits into two independent tracks: +1. **Logo storage track:** Image format detection from magic bytes, silent compression using `System.Drawing.Common` (available via WPF's `PresentationCore`/`System.Drawing.Common` BCL subset on net10.0-windows), base64 serialization in JSON. +2. **Graph directory track:** `PageIterator` from Microsoft.Graph 5.x following `@odata.nextLink` until exhausted, with `CancellationToken` threading throughout. + +Both tracks fit the existing patterns precisely. The repository uses `SemaphoreSlim(1,1)` + write-then-move. The Graph service clones `GraphUserSearchService` structure while substituting `PageIterator` for a one-shot `GetAsync`. No configuration, no new packages, no breaking changes. + +**Primary recommendation:** Implement in order — models first, then repository, then services, then DI registration, then update `ProfileManagementViewModel.DeleteAsync` warning message. Tests mirror the `SettingsServiceTests` and `ProfileServiceTests` patterns already present. + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions +| Decision | Value | +|---|---| +| Logo storage format | Base64 strings in JSON (not file paths) | +| MSP logo location | `BrandingSettings.cs` model → `branding.json` | +| Client logo location | On `TenantProfile` model (per-tenant) | +| File path after import | Discarded — only base64 persists | +| SVG support | Rejected (XSS risk) — PNG/JPG only | +| User directory service | New `GraphUserDirectoryService`, separate from `GraphUserSearchService` | +| Directory auto-load | No — explicit "Load Directory" button required | +| New NuGet packages | None — existing stack covers everything | +| Export service signature | Optional `ReportBranding? branding = null` parameter on existing export methods | + +### Claude's Discretion +- No discretion areas defined for Phase 10 — all decisions locked. + +### Deferred Ideas (OUT OF SCOPE) +- Logo preview in Settings UI (Phase 12) +- Auto-pull client logo from Entra branding API (Phase 11/12) +- Report header layout with logos side-by-side (Phase 11) +- "Load Directory" button placement decision (Phase 14) +- Session-scoped directory cache (UDIR-F01, deferred) + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| BRAND-01 | User can import an MSP logo in application settings (global, persisted across sessions) | `BrandingSettings` model + `BrandingRepository` (mirrors `SettingsRepository`) + `BrandingService.ImportLogoAsync` | +| BRAND-03 | User can import a client logo per tenant profile | `LogoData? ClientLogo` property on `TenantProfile` + `ProfileRepository` already handles serialization; `BrandingService.ImportLogoAsync` reused | +| BRAND-06 | Logo import validates format (PNG/JPG) and enforces 512 KB size limit | Magic byte detection (PNG: `89 50 4E 47`, JPEG: `FF D8 FF`) + auto-compress via `System.Drawing`/`BitmapEncoder` if > 512 KB | + + +--- + +## Standard Stack + +### Core (all already present — zero new installs) +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `System.Text.Json` | BCL (net10.0) | JSON serialization of models | Already used in all repositories | +| `System.Drawing.Common` | BCL (net10.0-windows) | Image load, resize, re-encode for compression | Available on Windows via `UseWPF=true`; no extra package | +| `Microsoft.Graph` | 5.74.0 (already in csproj) | Graph SDK for user enumeration | Already used by `GraphUserSearchService` | +| `Microsoft.Identity.Client` | 4.83.3 (already in csproj) | Token acquisition via `GraphClientFactory` | Already used | +| `CommunityToolkit.Mvvm` | 8.4.2 (already in csproj) | `[ObservableProperty]` for ViewModels — not used in Phase 10 directly, but referenced by `ProfileManagementViewModel` | Already used | + +### No New Packages +All capabilities are covered by the existing stack. Confirmed in CONTEXT.md locked decisions and csproj inspection. + +## Architecture Patterns + +### Recommended Project Structure (new files only) + +``` +SharepointToolbox/ +├── Core/ +│ └── Models/ +│ ├── LogoData.cs -- record LogoData(string Base64, string MimeType) +│ └── BrandingSettings.cs -- class BrandingSettings { LogoData? MspLogo; } +├── Infrastructure/ +│ └── Persistence/ +│ └── BrandingRepository.cs -- clone of SettingsRepository +├── Services/ +│ ├── IBrandingService.cs -- ImportLogoAsync, ClearLogoAsync +│ ├── BrandingService.cs -- validates magic bytes, compresses, returns LogoData +│ ├── IGraphUserDirectoryService.cs -- GetUsersAsync with PageIterator +│ └── GraphUserDirectoryService.cs -- PageIterator pagination + +SharepointToolbox.Tests/ +└── Services/ + ├── BrandingServiceTests.cs -- magic bytes, compression, rejection + └── GraphUserDirectoryServiceTests.cs -- pagination (mocked PageIterator or direct list) +``` + +### Pattern 1: Repository (write-then-move with SemaphoreSlim) + +Exact clone of `SettingsRepository` with `BrandingSettings` substituted for `AppSettings`. No deviations. + +```csharp +// Source: SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs (existing) +public class BrandingRepository +{ + private readonly string _filePath; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public async Task LoadAsync() + { + if (!File.Exists(_filePath)) + return new BrandingSettings(); + // ... File.ReadAllTextAsync + JsonSerializer.Deserialize ... + } + + public async Task SaveAsync(BrandingSettings settings) + { + await _writeLock.WaitAsync(); + try + { + var json = JsonSerializer.Serialize(settings, + new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + var tmpPath = _filePath + ".tmp"; + // ... write to tmp, validate round-trip, File.Move(tmp, _filePath, overwrite: true) ... + } + finally { _writeLock.Release(); } + } +} +``` + +### Pattern 2: LogoData record — shared by MSP and client logos + +```csharp +// Source: CONTEXT.md §1 Logo Metadata Model +namespace SharepointToolbox.Core.Models; + +public record LogoData(string Base64, string MimeType); +// MimeType is "image/png" or "image/jpeg" — determined at import time from magic bytes +// Usage in HTML: $"data:{MimeType};base64,{Base64}" +``` + +### Pattern 3: BrandingSettings model + +```csharp +namespace SharepointToolbox.Core.Models; + +public class BrandingSettings +{ + public LogoData? MspLogo { get; set; } +} +``` + +### Pattern 4: TenantProfile extension + +```csharp +// Extend existing TenantProfile — additive, no breaking change +public class TenantProfile +{ + public string Name { get; set; } = string.Empty; + public string TenantUrl { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public LogoData? ClientLogo { get; set; } // NEW — nullable, ignored when null in JSON +} +``` + +`ProfileRepository` needs no code change — `System.Text.Json` serializes the new nullable property automatically. Existing profiles JSON without `clientLogo` deserializes with `null` (forward-compatible). + +### Pattern 5: Magic byte validation + compression in BrandingService + +```csharp +// Source: CONTEXT.md §2 Logo Validation & Compression +private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47 }; +private static readonly byte[] JpegSignature = { 0xFF, 0xD8, 0xFF }; + +private static string? DetectMimeType(byte[] header) +{ + if (header.Length >= 4 && header.Take(4).SequenceEqual(PngSignature)) return "image/png"; + if (header.Length >= 3 && header.Take(3).SequenceEqual(JpegSignature)) return "image/jpeg"; + return null; +} + +public async Task ImportLogoAsync(string filePath) +{ + var bytes = await File.ReadAllBytesAsync(filePath); + var mimeType = DetectMimeType(bytes) + ?? throw new InvalidDataException("File format is not PNG or JPG. Only PNG and JPG are accepted."); + + if (bytes.Length > 512 * 1024) + bytes = CompressToLimit(bytes, mimeType, maxBytes: 512 * 1024); + + return new LogoData(Convert.ToBase64String(bytes), mimeType); +} +``` + +For compression, use `System.Drawing.Bitmap` (available on net10.0-windows) to resize to max 300×300px and re-encode at reduced quality using `System.Drawing.Imaging.ImageCodecInfo`/`EncoderParameters`. Keep original format. + +### Pattern 6: GraphUserDirectoryService with PageIterator + +Microsoft.Graph 5.x includes `PageIterator` in `Microsoft.Graph.Core`. Pattern from Graph SDK docs: + +```csharp +// Source: Microsoft.Graph 5.x SDK — PageIterator pattern +public async Task> GetUsersAsync( + string clientId, + CancellationToken ct = default) +{ + var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct); + var results = new List(); + + var response = await graphClient.Users.GetAsync(config => + { + config.QueryParameters.Filter = "accountEnabled eq true and userType eq 'Member'"; + config.QueryParameters.Select = new[] { "displayName", "userPrincipalName", "mail", "department", "jobTitle" }; + config.QueryParameters.Top = 999; + }, ct); + + if (response is null) return results; + + var pageIterator = PageIterator.CreatePageIterator( + graphClient, + response, + user => + { + results.Add(new GraphDirectoryUser( + user.DisplayName ?? user.UserPrincipalName ?? string.Empty, + user.UserPrincipalName ?? string.Empty, + user.Mail, + user.Department, + user.JobTitle)); + return true; // continue iteration + }); + + await pageIterator.IterateAsync(ct); + return results; +} +``` + +`PageIterator` requires `Microsoft.Graph.Core` which is a transitive dependency of `Microsoft.Graph` 5.x — already present. + +**No `ConsistencyLevel: eventual` needed** for the `$filter` query with `accountEnabled` and `userType` — these are standard properties, not advanced queries requiring `$count`. (Unlike the search service which uses `startsWith` and requires `ConsistencyLevel`.) + +### Pattern 7: DI registration (App.xaml.cs) + +```csharp +// Phase 10: Branding Data Foundation +services.AddSingleton(_ => new BrandingRepository(Path.Combine(appData, "branding.json"))); +services.AddSingleton(); +services.AddTransient(); +``` + +`BrandingRepository` is Singleton (same rationale as `ProfileRepository` and `SettingsRepository` — single file, shared lock). `BrandingService` is Singleton (stateless after construction, depends on singleton repository). `GraphUserDirectoryService` is Transient (per-tenant call, stateless). + +### Pattern 8: ProfileManagementViewModel deletion message update + +In `ProfileManagementViewModel.DeleteAsync()`, the existing confirmation flow has no dialog — it directly calls `_profileService.DeleteProfileAsync`. The update per CONTEXT.md is to augment the confirmation message (when that dialog exists) to mention logo removal. However, Phase 10 does not add a confirmation dialog — that is the caller's concern (View layer, Phase 12). The ViewModel update is to expose information about whether a profile has a logo, enabling Phase 12's View to conditionally show the warning. + +```csharp +// Add a computed property to support the deletion warning in Phase 12 +// This is the minimal Phase 10 change: +// TenantProfile.ClientLogo != null → the confirmation dialog (Phase 12) reads this +``` + +The actual deletion behavior is unchanged: deleting the profile JSON entry automatically drops the embedded `clientLogo` field. No orphaned files exist. + +### Anti-Patterns to Avoid + +- **Do not store the file path in JSON** — only base64 + MIME type. File path is discarded immediately after reading bytes. +- **Do not use file extension for format detection** — always read magic bytes from the byte array. +- **Do not use `$search` or `$count` on the directory query** — `PageIterator` with `$filter=accountEnabled eq true and userType eq 'Member'` does not require `ConsistencyLevel: eventual`. +- **Do not create a new interface for BrandingRepository** — `SettingsRepository` has no interface either; only services get interfaces. +- **Do not add `[ObservableProperty]` to `LogoData`** — it is a plain record used in persistence layer; ViewModel bindings come in Phase 11-12. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| JSON pagination follow-up | Manual `@odata.nextLink` string parsing loop | `PageIterator` | SDK handles retry, null checks, async iteration natively | +| Image format detection | File extension check | Magic byte read on first 4 bytes | Extensions are user-controlled and unreliable | +| Atomic file write | Direct `File.WriteAllText` | Write to `.tmp`, validate, `File.Move(overwrite:true)` | Crash during write leaves corrupted JSON; pattern already proven in all repos | +| Concurrency guard | `lock(obj)` | `SemaphoreSlim(1,1)` | Async-safe; `lock` cannot be awaited | +| Base64 encoding | Manual byte-to-char loop | `Convert.ToBase64String(bytes)` | BCL, zero allocation path, no edge cases | + +## Common Pitfalls + +### Pitfall 1: `System.Drawing` availability on net10.0-windows +**What goes wrong:** `System.Drawing.Common` is available on Windows (the project already targets `net10.0-windows` with `UseWPF=true`) but would throw `PlatformNotSupportedException` on Linux/macOS runtimes. +**Why it happens:** .NET 6+ restricted `System.Drawing.Common` to Windows-only by default. +**How to avoid:** This project is Windows-only (WinExe, UseWPF=true) so no risk. No guard needed. +**Warning signs:** CI on Linux — not applicable here. + +### Pitfall 2: `PageIterator.IterateAsync` does not accept `CancellationToken` directly in Graph SDK 5.x +**What goes wrong:** `PageIterator.IterateAsync()` in Microsoft.Graph 5.x overloads — the token must be passed when calling `CreatePageIterator`, and the iteration callback must check cancellation manually or the token goes to `IterateAsync(ct)` if the overload exists. +**Why it happens:** API surface changed between SDK versions. +**How to avoid:** Check token inside the callback: `if (ct.IsCancellationRequested) return false;` stops iteration. Also pass `ct` to the initial `GetAsync` call. +**Warning signs:** Long-running enumeration that ignores cancellation requests. + +### Pitfall 3: Deserialization of `LogoData` record with `System.Text.Json` +**What goes wrong:** C# records with positional constructors may not deserialize correctly with `System.Text.Json` unless the property names match constructor parameter names exactly (case-insensitive with `PropertyNameCaseInsensitive = true`) or a `[JsonConstructor]` attribute is present. +**Why it happens:** Positional record constructor parameters are `base64` and `mimeType` (camelCase) while JSON uses `PropertyNamingPolicy.CamelCase`. +**How to avoid:** Use a class with `{ get; set; }` properties OR add `[JsonConstructor]` to the positional record constructor. Simpler: make `LogoData` a class with init setters or a non-positional record with `{ get; init; }` properties. + +```csharp +// SAFE version — class-style record with init setters: +public record LogoData +{ + public string Base64 { get; init; } = string.Empty; + public string MimeType { get; init; } = string.Empty; +} +``` + +### Pitfall 4: Large base64 string bloating profiles.json +**What goes wrong:** A 512 KB logo becomes ~682 KB of base64 text. Per-profile, this is manageable. However, `ProfileRepository.LoadAsync` loads ALL profiles at once — 20 tenants with logos = ~14 MB in memory per load. +**Why it happens:** All profiles are stored in a single JSON array. +**How to avoid:** Phase 10 does not address this (deferred); the 512 KB cap keeps it bounded. Document as known limitation. +**Warning signs:** Not a Phase 10 concern — flag for future phases if profile count grows large. + +### Pitfall 5: `File.Move` with `overwrite: true` not available on all .NET versions +**What goes wrong:** `File.Move(src, dst, overwrite: true)` was added in .NET 3.0. On older frameworks this throws. +**Why it happens:** Legacy API surface. +**How to avoid:** Not applicable — project targets net10.0. Use freely. + +### Pitfall 6: Graph $filter without ConsistencyLevel on advanced queries +**What goes wrong:** The search service uses `startsWith()` which requires `ConsistencyLevel: eventual + $count=true`. If the directory service accidentally includes `$count` or `$search`, it needs the header too. +**Why it happens:** Copy-paste from `GraphUserSearchService` without removing the `ConsistencyLevel` header. +**How to avoid:** The directory filter `accountEnabled eq true and userType eq 'Member'` is a standard equality filter — does NOT require `ConsistencyLevel: eventual`. Do not copy the header from `GraphUserSearchService`. + +## Code Examples + +### Magic Byte Detection +```csharp +// Source: CONTEXT.md §2 Logo Validation; confirmed against PNG/JPEG specs +private static readonly byte[] PngMagic = { 0x89, 0x50, 0x4E, 0x47 }; +private static readonly byte[] JpegMagic = { 0xFF, 0xD8, 0xFF }; + +private static string? DetectMimeType(ReadOnlySpan header) +{ + if (header.Length >= 4 && header[..4].SequenceEqual(PngMagic)) return "image/png"; + if (header.Length >= 3 && header[..3].SequenceEqual(JpegMagic)) return "image/jpeg"; + return null; +} +``` + +### Compression via System.Drawing (net10.0-windows) +```csharp +// Source: BCL System.Drawing.Common — Windows-only, safe here +private static byte[] CompressImage(byte[] original, string mimeType, int maxBytes) +{ + using var ms = new MemoryStream(original); + using var bitmap = new System.Drawing.Bitmap(ms); + + // Scale down proportionally to max 300px + int w = bitmap.Width, h = bitmap.Height; + if (w > 300 || h > 300) + { + double scale = Math.Min(300.0 / w, 300.0 / h); + w = (int)(w * scale); + h = (int)(h * scale); + } + + using var resized = new System.Drawing.Bitmap(bitmap, w, h); + + // Re-encode + var codec = System.Drawing.Imaging.ImageCodecInfo.GetImageEncoders() + .First(c => c.MimeType == mimeType); + var encoderParams = new System.Drawing.Imaging.EncoderParameters(1); + encoderParams.Param[0] = new System.Drawing.Imaging.EncoderParameter( + System.Drawing.Imaging.Encoder.Quality, 75L); + + using var output = new MemoryStream(); + resized.Save(output, codec, encoderParams); + return output.ToArray(); +} +``` + +### PageIterator pattern (Microsoft.Graph 5.x) +```csharp +// Source: Microsoft.Graph 5.x SDK pattern; PageIterator +var pageIterator = PageIterator.CreatePageIterator( + graphClient, + firstPage, + user => + { + if (ct.IsCancellationRequested) return false; + results.Add(MapUser(user)); + return true; + }); + +await pageIterator.IterateAsync(ct); +``` + +### GraphDirectoryUser result record +```csharp +// New record for Phase 10 — placed in Services/ or Core/Models/ +public record GraphDirectoryUser( + string DisplayName, + string UserPrincipalName, + string? Mail, + string? Department, + string? JobTitle); +``` + +### JSON shape of branding.json +```json +{ + "mspLogo": { + "base64": "iVBORw0KGgo...", + "mimeType": "image/png" + } +} +``` + +### JSON shape of profiles.json (after Phase 10) +```json +{ + "profiles": [ + { + "name": "Contoso", + "tenantUrl": "https://contoso.sharepoint.com", + "clientId": "...", + "clientLogo": { + "base64": "/9j/4AAQ...", + "mimeType": "image/jpeg" + } + }, + { + "name": "Fabrikam", + "tenantUrl": "https://fabrikam.sharepoint.com", + "clientId": "...", + "clientLogo": null + } + ] +} +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Manual `@odata.nextLink` loop | `PageIterator` | Microsoft.Graph 5.x (current) | Handles backoff, null-safety, async natively | +| `System.Drawing` everywhere | `System.Drawing` Windows-only | .NET 6 | No impact here — Windows-only project | +| Class-based Graph response models | Record/POCO `Value` collections | Microsoft.Graph 5.x | `response.Value` is `List?` | + +**Deprecated/outdated:** +- `Microsoft.Graph.Beta` namespace: not needed here — standard `/v1.0/users` endpoint sufficient +- `IAuthenticationProvider` (old Graph SDK): replaced by `BaseBearerTokenAuthenticationProvider` — already correct in `GraphClientFactory` + +## Open Questions + +1. **CancellationToken in PageIterator.IterateAsync — exact overload in Graph SDK 5.74.0** + - What we know: `PageIterator` exists in `Microsoft.Graph.Core`; `IterateAsync` exists. Token passing confirmed in SDK samples. + - What's unclear: Whether `IterateAsync(CancellationToken)` overload exists in 5.74.0 or only the parameterless version. + - Recommendation: Check when implementing. If parameterless only, use `ct.IsCancellationRequested` inside callback to return `false` and stop iteration. Either approach works correctly. + +2. **$filter=accountEnabled eq true and userType eq 'Member' — verified against real tenant?** + - STATE.md flags this as a pending todo: "Confirm `$filter=accountEnabled eq true and userType eq 'Member'` behavior without `ConsistencyLevel: eventual` against a real tenant before Phase 13 planning." + - Phase 10 implements the service; Phase 13 will exercise the filter in the ViewModel. The pending verification is appropriate for Phase 13. + - Recommendation: Implement the filter as specified. Flag in `GraphUserDirectoryService` with a comment noting the pending verification. + +--- + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | xUnit 2.9.3 + Moq 4.20.72 | +| Config file | `SharepointToolbox.Tests/SharepointToolbox.Tests.csproj` | +| Quick run command | `dotnet test --filter "Category=Unit" --no-build` | +| Full suite command | `dotnet test` | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| BRAND-01 | MSP logo saved to `branding.json` and reloaded correctly | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 | +| BRAND-01 | `BrandingRepository` round-trips `BrandingSettings` with `MspLogo` | unit | `dotnet test --filter "FullyQualifiedName~BrandingRepositoryTests" --no-build` | ❌ Wave 0 | +| BRAND-03 | `TenantProfile.ClientLogo` serializes/deserializes in `ProfileRepository` | unit | `dotnet test --filter "FullyQualifiedName~ProfileServiceTests" --no-build` | ✅ (extend existing) | +| BRAND-06 | PNG file accepted, returns `image/png` MIME | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 | +| BRAND-06 | JPEG file accepted, returns `image/jpeg` MIME | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 | +| BRAND-06 | BMP file rejected with descriptive error | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 | +| BRAND-06 | File > 512 KB is auto-compressed (output ≤ 512 KB) | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 | +| BRAND-06 | File ≤ 512 KB is not modified | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 | +| (UDIR-02 infra) | `GetUsersAsync` follows all pages until exhausted | unit | `dotnet test --filter "FullyQualifiedName~GraphUserDirectoryServiceTests" --no-build` | ❌ Wave 0 | +| (UDIR-02 infra) | `GetUsersAsync` respects `CancellationToken` mid-iteration | unit | `dotnet test --filter "FullyQualifiedName~GraphUserDirectoryServiceTests" --no-build` | ❌ Wave 0 | + +### Sampling Rate +- **Per task commit:** `dotnet test --filter "Category=Unit" --no-build` +- **Per wave merge:** `dotnet test` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps +- [ ] `SharepointToolbox.Tests/Services/BrandingServiceTests.cs` — covers BRAND-06 + BRAND-01 import logic +- [ ] `SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs` — covers BRAND-01 persistence +- [ ] `SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs` — covers UDIR-02 infrastructure + +*(Extend existing `ProfileServiceTests.cs` to verify `ClientLogo` round-trip — covers BRAND-03)* + +--- + +## Sources + +### Primary (HIGH confidence) +- Codebase inspection — `SettingsRepository.cs`, `ProfileRepository.cs`, `GraphUserSearchService.cs`, `GraphClientFactory.cs`, `App.xaml.cs`, `TenantProfile.cs`, `AppSettings.cs` +- `SharepointToolbox.csproj` — confirms Microsoft.Graph 5.74.0, no System.Drawing explicit reference needed (net10.0-windows) +- `SharepointToolbox.Tests.csproj` — confirms xUnit 2.9.3, Moq 4.20.72 test stack +- `10-CONTEXT.md` — locked decisions, compression strategy, magic byte specs, model shapes + +### Secondary (MEDIUM confidence) +- Microsoft.Graph 5.x SDK architecture — `PageIterator` pattern confirmed in Graph SDK source and documentation; version 5.74.0 is current +- `System.Drawing.Common` Windows availability — confirmed by .NET documentation: available on Windows, restricted on non-Windows since .NET 6 + +### Tertiary (LOW confidence) +- `PageIterator.IterateAsync(CancellationToken)` overload availability in 5.74.0 specifically — needs compile-time verification + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — confirmed from csproj; zero new packages +- Architecture: HIGH — all patterns are direct clones of existing code in the repo +- Magic byte detection: HIGH — PNG/JPEG signatures are stable specs +- PageIterator pattern: MEDIUM — SDK version-specific overload needs verification at implementation time +- Pitfalls: HIGH — identified from codebase inspection and known .NET behaviors + +**Research date:** 2026-04-08 +**Valid until:** 2026-05-08 (stable domain — Microsoft.Graph minor versions change rarely)