29 KiB
Phase 11: HTML Export Branding + ViewModel Integration - Research
Researched: 2026-04-08 Domain: C#/.NET 10/WPF - HTML report branding, ViewModel commands, Microsoft Graph organizational branding API Confidence: HIGH
Summary
Phase 11 adds logo branding to all five HTML report types and provides ViewModel commands for managing MSP and client logos. The core infrastructure (LogoData, BrandingSettings, IBrandingService, TenantProfile.ClientLogo) was built in Phase 10 and is solid. This phase connects that infrastructure to the export pipeline and adds user-facing commands.
The main technical challenges are: (1) injecting a branding header into 5+2 StringBuilder-based HTML exporters without excessive duplication, (2) designing the branding flow from ViewModel through export service, and (3) implementing the Entra branding API auto-pull for client logos. All of these are straightforward given the existing patterns.
Primary recommendation: Create a static BrandingHtmlHelper class with a single BuildBrandingHeader(ReportBranding?) method that all exporters call. Add a ReportBranding record bundling MSP + client LogoData. Each export ViewModel already has _currentProfile (with ClientLogo) and can inject IBrandingService to get the MSP logo.
<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.MspLogo via BrandingRepository |
| Client logo location | TenantProfile.ClientLogo (per-tenant, in profile JSON) |
| Logo model | LogoData { string Base64, string MimeType } -- shared by both MSP and client logos |
| SVG support | Rejected (XSS risk) -- PNG/JPG only |
| Export service signature change | Optional ReportBranding? branding = null parameter on existing BuildHtml methods |
| No new interfaces | No IHtmlExportService<T> -- keep concrete classes with optional branding param |
| Report header layout | display: flex; gap: 16px -- MSP logo left, client logo right |
| Logo HTML format | <img src="data:{MimeType};base64,{Base64}"> inline data-URI |
| No new NuGet packages | All capabilities provided by existing stack |
Claude's Discretion
None explicitly stated -- all key decisions are locked.
Deferred Ideas (OUT OF SCOPE)
- Logo preview in Settings UI (Phase 12)
- Live thumbnail preview after import (Phase 12)
- "Pull from Entra" button in profile dialog UI (Phase 12)
- User directory browse mode (Phase 13-14) </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| BRAND-05 | All five HTML report types display MSP and client logos in a consistent header | BrandingHtmlHelper pattern, ReportBranding model, BuildHtml signature changes, WriteAsync signature changes |
| BRAND-04 | User can auto-pull client logo from tenant's Entra branding API | Graph API endpoint research, squareLogo stream retrieval, 404 handling, ProfileService.UpdateProfileAsync |
| </phase_requirements> |
Standard Stack
Core (already installed -- no new packages)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| Microsoft.Graph | 5.74.0 | Entra branding API for auto-pull | Already in project for user directory service |
| CommunityToolkit.Mvvm | (project ver) | AsyncRelayCommand, ObservableProperty | Already used in all ViewModels |
| Microsoft.Win32 (WPF) | built-in | OpenFileDialog for logo browse | Already used in SettingsViewModel.BrowseFolder |
No New Dependencies
All required functionality is provided by the existing stack. The Graph SDK is already installed and authenticated via GraphClientFactory.
Architecture Patterns
Recommended Project Structure
SharepointToolbox/
Core/Models/
LogoData.cs # (exists) record { Base64, MimeType }
BrandingSettings.cs # (exists) { LogoData? MspLogo }
TenantProfile.cs # (exists) { LogoData? ClientLogo }
ReportBranding.cs # NEW - bundles MSP + client for export
Services/
IBrandingService.cs # (exists) + no changes needed
BrandingService.cs # (exists) + no changes needed
ProfileService.cs # (exists) + add UpdateProfileAsync
Export/
BrandingHtmlHelper.cs # NEW - shared branding header HTML builder
HtmlExportService.cs # MODIFY - add branding param to BuildHtml/WriteAsync
SearchHtmlExportService.cs # MODIFY - same
StorageHtmlExportService.cs # MODIFY - same
DuplicatesHtmlExportService.cs # MODIFY - same
UserAccessHtmlExportService.cs # MODIFY - same
ViewModels/
Tabs/SettingsViewModel.cs # MODIFY - add MSP logo commands
ProfileManagementViewModel.cs # MODIFY - add client logo commands
Tabs/PermissionsViewModel.cs # MODIFY - pass branding to export
Tabs/SearchViewModel.cs # MODIFY - pass branding to export
Tabs/StorageViewModel.cs # MODIFY - pass branding to export
Tabs/DuplicatesViewModel.cs # MODIFY - pass branding to export
Tabs/UserAccessAuditViewModel.cs # MODIFY - pass branding to export
Pattern 1: ReportBranding Record
What: A simple record that bundles both logos for passing to export services. When to use: Every time an export method is called. Example:
// Source: project convention (records for immutable DTOs)
namespace SharepointToolbox.Core.Models;
public record ReportBranding(LogoData? MspLogo, LogoData? ClientLogo);
Rationale: Export services should not know about IBrandingService or ProfileService. The ViewModel assembles branding from both sources and passes it as a simple DTO. This keeps export services pure (data in, HTML out).
Pattern 2: BrandingHtmlHelper (Static Helper)
What: A static class that generates the branding header HTML fragment.
When to use: Called by each export service's BuildHtml method.
Example:
// Source: project convention (static helpers for shared concerns)
namespace SharepointToolbox.Services.Export;
internal static class BrandingHtmlHelper
{
/// <summary>
/// Returns the branding header HTML (flex container with logo img tags),
/// or empty string if no logos are configured.
/// </summary>
public static string BuildBrandingHeader(ReportBranding? branding)
{
if (branding is null) return string.Empty;
var msp = branding.MspLogo;
var client = branding.ClientLogo;
if (msp is null && client is null) return string.Empty;
var sb = new StringBuilder();
sb.AppendLine("<div class=\"branding-header\" style=\"display:flex;gap:16px;align-items:center;padding:12px 24px;\">");
if (msp is not null)
sb.AppendLine($" <img src=\"data:{msp.MimeType};base64,{msp.Base64}\" alt=\"MSP Logo\" style=\"max-height:60px;max-width:200px;object-fit:contain;\">");
// Spacer pushes client logo to the right
if (msp is not null && client is not null)
sb.AppendLine(" <div style=\"flex:1\"></div>");
if (client is not null)
sb.AppendLine($" <img src=\"data:{client.MimeType};base64,{client.Base64}\" alt=\"Client Logo\" style=\"max-height:60px;max-width:200px;object-fit:contain;\">");
sb.AppendLine("</div>");
return sb.ToString();
}
/// <summary>
/// Returns CSS for the branding header to include in the style block.
/// </summary>
public static string BuildBrandingCss()
{
return ".branding-header { margin-bottom: 8px; }";
}
}
Key design decisions:
max-height: 60pxkeeps logos reasonable in report headersmax-width: 200pxprevents oversized logos from dominatingobject-fit: containpreserves aspect ratio- Flex spacer pushes client logo to the right when both present
- Returns empty string (not null) when no branding -- callers don't need null checks
- Handles all 3 states: both logos, one only, none
Pattern 3: BuildHtml Signature Extension
What: Add optional ReportBranding? branding = null to all BuildHtml and WriteAsync methods.
When to use: All 5 export service classes, all 7 WriteAsync overloads.
Example:
// Before:
public string BuildHtml(IReadOnlyList<PermissionEntry> entries)
// After:
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null)
Injection point in each exporter:
// After: sb.AppendLine("<body>");
// Before: sb.AppendLine("<h1>...");
// Insert:
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
Default null ensures backward compatibility -- existing callers without branding continue to work identically.
Pattern 4: ViewModel Branding Assembly
What: Export ViewModels assemble ReportBranding before calling export.
When to use: In each export command handler (e.g., ExportHtmlAsync).
Example:
// In PermissionsViewModel.ExportHtmlAsync:
private async Task ExportHtmlAsync()
{
if (_htmlExportService == null || Results.Count == 0) return;
// ... dialog code ...
// Assemble branding from injected services
var mspLogo = await _brandingService.GetMspLogoAsync();
var clientLogo = _currentProfile?.ClientLogo;
var branding = new ReportBranding(mspLogo, clientLogo);
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding);
}
Key insight: Each export ViewModel already has _currentProfile (set via TenantSwitchedMessage). It just needs IBrandingService injected for the MSP logo. No new service composition needed.
Pattern 5: SettingsViewModel Logo Commands
What: Browse/clear commands for MSP logo using existing patterns. When to use: SettingsViewModel only. Example:
// Following existing BrowseFolderCommand pattern (synchronous RelayCommand)
// But logo operations are async, so use AsyncRelayCommand
private readonly IBrandingService _brandingService;
// Properties for Phase 12 UI binding (just expose, no UI yet)
private string? _mspLogoPreview;
public string? MspLogoPreview
{
get => _mspLogoPreview;
private set { _mspLogoPreview = value; OnPropertyChanged(); }
}
public IAsyncRelayCommand BrowseMspLogoCommand { get; }
public IAsyncRelayCommand ClearMspLogoCommand { get; }
private async Task BrowseMspLogoAsync()
{
var dialog = new OpenFileDialog
{
Title = "Select MSP logo",
Filter = "Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg",
};
if (dialog.ShowDialog() != true) return;
try
{
var logo = await _brandingService.ImportLogoAsync(dialog.FileName);
await _brandingService.SaveMspLogoAsync(logo);
MspLogoPreview = $"data:{logo.MimeType};base64,{logo.Base64}";
}
catch (Exception ex)
{
StatusMessage = ex.Message;
}
}
private async Task ClearMspLogoAsync()
{
await _brandingService.ClearMspLogoAsync();
MspLogoPreview = null;
}
Pattern 6: ProfileManagementViewModel Client Logo Commands
What: Browse/clear/auto-pull commands for client logo.
When to use: ProfileManagementViewModel only.
Key difference from MSP: Client logo is stored on TenantProfile.ClientLogo and persisted through ProfileService, not IBrandingService.
public IAsyncRelayCommand BrowseClientLogoCommand { get; }
public IAsyncRelayCommand ClearClientLogoCommand { get; }
public IAsyncRelayCommand AutoPullClientLogoCommand { get; }
private async Task BrowseClientLogoAsync()
{
if (SelectedProfile == null) return;
var dialog = new OpenFileDialog
{
Title = "Select client logo",
Filter = "Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg",
};
if (dialog.ShowDialog() != true) return;
var logo = await _brandingService.ImportLogoAsync(dialog.FileName);
SelectedProfile.ClientLogo = logo;
await _profileService.UpdateProfileAsync(SelectedProfile);
}
private async Task ClearClientLogoAsync()
{
if (SelectedProfile == null) return;
SelectedProfile.ClientLogo = null;
await _profileService.UpdateProfileAsync(SelectedProfile);
}
Pattern 7: ProfileService.UpdateProfileAsync
What: New method to update an existing profile in the list and persist.
When to use: When modifying a profile's ClientLogo.
Rationale: ProfileService currently has Add/Rename/Delete but no Update. We need one for client logo changes.
public async Task UpdateProfileAsync(TenantProfile profile)
{
var profiles = (await _repository.LoadAsync()).ToList();
var idx = profiles.FindIndex(p => p.Name == profile.Name);
if (idx < 0) throw new KeyNotFoundException($"Profile '{profile.Name}' not found.");
profiles[idx] = profile;
await _repository.SaveAsync(profiles);
}
Anti-Patterns to Avoid
- Injecting IBrandingService into export services: Export services should remain pure data-to-HTML transformers. Branding data flows in via
ReportBrandingparameter. - Creating a separate "branding provider" service: Unnecessary indirection. ViewModels already have both data sources (
IBrandingService+_currentProfile). - Modifying existing method signatures non-optionally: Would break all existing callers and tests. Default
nullparameter preserves backward compatibility. - Duplicating branding HTML in each exporter: Use
BrandingHtmlHelperto centralize the header generation.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| File dialog for logo selection | Custom file picker | Microsoft.Win32.OpenFileDialog |
WPF standard, already used in SettingsViewModel |
| Logo validation/compression | Custom image processing | IBrandingService.ImportLogoAsync |
Already validates PNG/JPG magic bytes and auto-compresses >512KB |
| HTML encoding in export helpers | Manual string replacement | Use existing HtmlEncode method in each service or System.Net.WebUtility.HtmlEncode |
XSS prevention |
| Graph API auth for Entra branding | Manual HTTP + token | GraphClientFactory.CreateClientAsync |
Already handles MSAL auth flow |
Common Pitfalls
Pitfall 1: Broken Images When Logo Is Missing
What goes wrong: If branding header renders <img> tags for missing logos, the report shows broken image icons.
Why it happens: Not checking for null LogoData before generating <img> tag.
How to avoid: BrandingHtmlHelper.BuildBrandingHeader checks each logo for null individually. If both are null, returns empty string. No <img> tag is emitted without valid data.
Warning signs: Visual broken-image icons in exported HTML when no logos configured.
Pitfall 2: WriteAsync Parameter Order Confusion
What goes wrong: Adding ReportBranding? parameter in wrong position causes ambiguity or breaks existing callers.
Why it happens: Some WriteAsync overloads have different parameter counts already.
How to avoid: Always add ReportBranding? branding = null as the LAST parameter before or after CancellationToken. Convention: place it after filePath and before CancellationToken for consistency, but since it's optional and CT is not, place after CT:
WriteAsync(data, filePath, CancellationToken, ReportBranding? branding = null)
This way existing callers pass positional args without change. Warning signs: Compiler errors in existing test files.
Pitfall 3: Graph API 404 for Unbranded Tenants
What goes wrong: Auto-pull throws unhandled exception when tenant has no Entra branding configured.
Why it happens: Graph returns 404 when no branding exists, and ODataError when stream is not set (empty response body with 200).
How to avoid: Wrap Graph call in try/catch for ServiceException/ODataError. On 404 or empty stream, return gracefully (null logo) instead of throwing. Log informational message.
Warning signs: Unhandled exceptions in ProfileManagementViewModel when testing with tenants that have no branding.
Pitfall 4: Thread Affinity for OpenFileDialog
What goes wrong: OpenFileDialog.ShowDialog() called from non-UI thread throws.
Why it happens: AsyncRelayCommand runs on thread pool by default.
How to avoid: The dialog call itself is synchronous and runs before any await. In the CommunityToolkit.Mvvm pattern, AsyncRelayCommand invokes the delegate on the calling thread (UI thread for command binding). The dialog opens before any async work begins. This matches the existing BrowseFolderCommand pattern.
Warning signs: InvalidOperationException at runtime.
Pitfall 5: StorageHtmlExportService Has Mutable State
What goes wrong: _togIdx instance field means the service is not stateless.
Why it happens: StorageHtmlExportService uses _togIdx for collapsible row IDs and resets it in BuildHtml.
How to avoid: When adding the branding parameter, don't change the _togIdx reset logic. The _togIdx = 0 at the start of each BuildHtml call handles this correctly.
Warning signs: Duplicate HTML IDs in storage reports if reset is accidentally removed.
Code Examples
Complete BrandingHtmlHelper Implementation
// Source: derived from CONTEXT.md locked decisions
using System.Text;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services.Export;
internal static class BrandingHtmlHelper
{
public static string BuildBrandingHeader(ReportBranding? branding)
{
if (branding is null) return string.Empty;
var msp = branding.MspLogo;
var client = branding.ClientLogo;
if (msp is null && client is null) return string.Empty;
var sb = new StringBuilder();
sb.AppendLine("<div style=\"display:flex;gap:16px;align-items:center;padding:12px 24px 0;\">");
if (msp is not null)
sb.AppendLine($" <img src=\"data:{msp.MimeType};base64,{msp.Base64}\" alt=\"\" style=\"max-height:60px;max-width:200px;object-fit:contain;\">");
if (msp is not null && client is not null)
sb.AppendLine(" <div style=\"flex:1\"></div>");
if (client is not null)
sb.AppendLine($" <img src=\"data:{client.MimeType};base64,{client.Base64}\" alt=\"\" style=\"max-height:60px;max-width:200px;object-fit:contain;\">");
sb.AppendLine("</div>");
return sb.ToString();
}
}
Entra Branding Auto-Pull (squareLogo)
// Source: Microsoft Learn - GET organizationalBrandingLocalization bannerLogo
// Endpoint: GET /organization/{orgId}/branding/localizations/default/squareLogo
// Returns: Stream (image/*) or empty 200 when not set, 404 when no branding at all
private async Task AutoPullClientLogoAsync()
{
if (SelectedProfile == null) return;
try
{
var graphClient = await _graphClientFactory.CreateClientAsync(
SelectedProfile.ClientId, CancellationToken.None);
// Get organization ID first
var orgs = await graphClient.Organization.GetAsync();
var orgId = orgs?.Value?.FirstOrDefault()?.Id;
if (orgId is null) { ValidationMessage = "Could not determine organization ID."; return; }
// Fetch squareLogo stream
var stream = await graphClient.Organization[orgId]
.Branding.Localizations["default"].SquareLogo.GetAsync();
if (stream is null || stream.Length == 0)
{
ValidationMessage = "No branding logo found for this tenant.";
return;
}
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var bytes = ms.ToArray();
// Detect MIME type via BrandingService (validates PNG/JPG)
var logo = await _brandingService.ImportLogoFromBytesAsync(bytes);
SelectedProfile.ClientLogo = logo;
await _profileService.UpdateProfileAsync(SelectedProfile);
}
catch (Microsoft.Graph.Models.ODataErrors.ODataError ex) when (ex.ResponseStatusCode == 404)
{
ValidationMessage = "No Entra branding configured for this tenant.";
}
catch (Exception ex)
{
ValidationMessage = $"Failed to pull logo: {ex.Message}";
_logger.LogWarning(ex, "Auto-pull client logo failed.");
}
}
ExportHtml with Branding Assembly
// Source: existing PermissionsViewModel.ExportHtmlAsync pattern
private async Task ExportHtmlAsync()
{
if (_htmlExportService == null || Results.Count == 0) return;
var dialog = new SaveFileDialog { /* existing dialog setup */ };
if (dialog.ShowDialog() != true) return;
try
{
// NEW: assemble branding
var mspLogo = await _brandingService.GetMspLogoAsync();
var clientLogo = _currentProfile?.ClientLogo;
var branding = new ReportBranding(mspLogo, clientLogo);
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
await _htmlExportService.WriteAsync(
SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding);
else
await _htmlExportService.WriteAsync(
Results, dialog.FileName, CancellationToken.None, branding);
OpenFile(dialog.FileName);
}
catch (Exception ex)
{
StatusMessage = $"Export failed: {ex.Message}";
}
}
Entra Branding API Details
Endpoint Selection: squareLogo vs bannerLogo
Recommendation: Use squareLogo (Confidence: HIGH)
| Logo Type | Dimensions | Use Case | Suitability for Reports |
|---|---|---|---|
| bannerLogo | Rectangle, ~280x36px | Sign-in page top banner | Too wide/thin for report headers |
| squareLogo | Square, ~240x240px | Sign-in page tile logo | Good fit for report headers at 60px height |
| squareLogoDark | Square | Dark mode variant | Not needed for HTML reports |
The squareLogo is the company tile logo used in sign-in pages. It renders well at the 60px max-height used in report headers because it's square and high-resolution.
API Details
| Property | Value |
|---|---|
| HTTP endpoint | GET /organization/{orgId}/branding/localizations/default/squareLogo |
| Graph SDK (C#) | graphClient.Organization[orgId].Branding.Localizations["default"].SquareLogo.GetAsync() |
| Response type | Stream (image bytes) |
| Content-Type | image/* (PNG or other image format) |
| No branding configured | 404 ODataError |
| Logo not set | 200 with empty body |
| Permission (delegated) | User.Read (least privileged) or Organization.Read.All |
| Permission (app) | OrganizationalBranding.Read.All |
Error Handling Strategy
// 404 = no branding configured at all -> inform user, not an error
// 200 empty = branding exists but no squareLogo set -> inform user
// Stream with data = success -> validate PNG/JPG, convert to LogoData
ImportLogoFromBytes Consideration
The existing BrandingService.ImportLogoAsync(string filePath) reads from file. For the Entra auto-pull, we receive bytes from a stream. Two options:
- Add
ImportLogoFromBytesAsync(byte[] bytes)to IBrandingService -- cleaner, avoids temp file - Write to temp file and call existing
ImportLogoAsync-- wasteful
Recommendation: Add a new method ImportLogoFromBytesAsync(byte[] bytes) that extracts the validation/compression logic from ImportLogoAsync. The existing method can delegate to it after reading the file.
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Graph SDK 4.x Organization.Branding | Graph SDK 5.x Localizations["default"].SquareLogo | SDK 5.0 (2023) | Different fluent API path |
| OrganizationalBranding.Read.All required | User.Read sufficient for delegated | v1.0 current | Lower permission bar |
Open Questions
-
Organization ID retrieval
- What we know: Graph SDK requires org ID for the branding endpoint.
GET /organizationreturns the tenant's organization list. - What's unclear: Whether the app already caches the org ID anywhere, or if we need a Graph call each time.
- Recommendation: Call
graphClient.Organization.GetAsync()and takeValue[0].Id. Cache it per-session if performance is a concern, but for a one-time auto-pull operation, a single extra call is acceptable.
- What we know: Graph SDK requires org ID for the branding endpoint.
-
MIME type detection from Graph stream
- What we know: Graph returns
image/*content-type. The actual bytes could be PNG, JPEG, or theoretically other formats. - What's unclear: Whether Graph always returns PNG for squareLogo or preserves original upload format.
- Recommendation: Use the existing
BrandingServicemagic-byte detection on the downloaded bytes. If it's not PNG/JPG, inform the user that the logo format is unsupported.
- What we know: Graph returns
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 SharepointToolbox.Tests --filter "FullyQualifiedName~Export" --no-build -q |
| Full suite command | dotnet test SharepointToolbox.Tests --no-build |
Phase Requirements -> Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| BRAND-05a | BrandingHtmlHelper produces correct HTML for both logos | unit | dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~BrandingHtmlHelper" --no-build -q |
No - Wave 0 |
| BRAND-05b | BrandingHtmlHelper produces empty string for no logos | unit | same as above | No - Wave 0 |
| BRAND-05c | BrandingHtmlHelper handles single logo (MSP only / client only) | unit | same as above | No - Wave 0 |
| BRAND-05d | HtmlExportService.BuildHtml with branding includes header | unit | dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~HtmlExportServiceTests" --no-build -q |
Yes (extend) |
| BRAND-05e | HtmlExportService.BuildHtml without branding unchanged | unit | same as above | Yes (extend) |
| BRAND-05f | Each of 5 exporters injects branding header between body and h1 | unit | dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Export" --no-build -q |
Partially (extend existing) |
| BRAND-04a | Auto-pull handles 404 (no branding) gracefully | unit | dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~AutoPull" --no-build -q |
No - Wave 0 |
| BRAND-04b | Auto-pull handles empty stream gracefully | unit | same as above | No - Wave 0 |
Sampling Rate
- Per task commit:
dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Export" --no-build -q - Per wave merge:
dotnet test SharepointToolbox.Tests --no-build - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
SharepointToolbox.Tests/Services/Export/BrandingHtmlHelperTests.cs-- covers BRAND-05a/b/cSharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs-- covers MSP logo commandsSharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs-- covers client logo + auto-pull (BRAND-04)- Extend existing
HtmlExportServiceTests.cs-- covers BRAND-05d/e - Extend existing
SearchExportServiceTests.cs,StorageHtmlExportServiceTests.cs,DuplicatesHtmlExportServiceTests.cs,UserAccessHtmlExportServiceTests.cs-- covers BRAND-05f
Sources
Primary (HIGH confidence)
- Project source code -- all Phase 10 infrastructure (LogoData, BrandingSettings, IBrandingService, BrandingService, TenantProfile, ProfileService, ProfileRepository)
- Project source code -- all 5 HTML export services (HtmlExportService, SearchHtmlExportService, StorageHtmlExportService, DuplicatesHtmlExportService, UserAccessHtmlExportService)
- Project source code -- ViewModels (SettingsViewModel, ProfileManagementViewModel, PermissionsViewModel, MainWindowViewModel, FeatureViewModelBase)
- Microsoft Learn - Get organizationalBranding -- Entra branding API, permissions, 404 behavior, C# SDK snippets
- Microsoft Learn - organizationalBrandingProperties -- squareLogo vs bannerLogo property descriptions
Secondary (MEDIUM confidence)
- Graph SDK 5.74.0 fluent API path for branding localizations -- verified via official docs C# snippets
Metadata
Confidence breakdown:
- Standard stack: HIGH - all libraries already in project, no new dependencies
- Architecture: HIGH - patterns derived directly from existing codebase conventions
- Pitfalls: HIGH - based on actual code inspection of all 5 exporters and ViewModels
- Entra branding API: HIGH - verified via official Microsoft Learn documentation with C# code samples
Research date: 2026-04-08 Valid until: 2026-05-08 (stable -- Graph v1.0 API, no breaking changes expected)