Files
Sharepoint-Toolbox/.planning/phases/10-branding-data-foundation/10-RESEARCH.md
2026-04-08 11:43:07 +02:00

531 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<User, UserCollectionResponse>` 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>
## 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)
</user_constraints>
---
<phase_requirements>
## 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 |
</phase_requirements>
---
## 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<BrandingSettings>
├── 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<BrandingSettings> LoadAsync()
{
if (!File.Exists(_filePath))
return new BrandingSettings();
// ... File.ReadAllTextAsync + JsonSerializer.Deserialize<BrandingSettings> ...
}
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<LogoData> 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<TEntity, TCollectionPage>` in `Microsoft.Graph.Core`. Pattern from Graph SDK docs:
```csharp
// Source: Microsoft.Graph 5.x SDK — PageIterator pattern
public async Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
string clientId,
CancellationToken ct = default)
{
var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct);
var results = new List<GraphDirectoryUser>();
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<User, UserCollectionResponse>.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<BrandingService>();
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
```
`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<User, UserCollectionResponse>` | 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<byte> 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<TEntity, TCollectionPage>
var pageIterator = PageIterator<User, UserCollectionResponse>.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<T, TPage>` | 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<User>?` |
**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<T, TPage>` 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)