Preconditions
- Tenant is on a plan/SKU that includes white-label (feature flag
white_label.enabled). - Tenant is signed in with role ≥ tenant-admin (not just event-organizer).
- R2 storage available for branded assets.
Happy path
-
Tenant admin opens Settings → Branding.
Form fields: logo upload (PNG/SVG, max 2MB), brand color picker (hex), accent color, typography family (allow-list of web-safe fonts + opt-in for self-hosted), footer text (rich text via ui-rich-text-editor with restricted schema — no scripts), sender-domain (verified via SPF/DKIM — references EF-060), legal-links section (privacy + ToS URLs).
-
Save publishes the brand profile.
POST creates a new brand_profiles row + uploads asset files to tenant-scoped R2 prefix. New profile is the active brand; previous becomes archived. Audit log row written.
-
Public surfaces render the brand.
Public event pages (booking-pages app), email templates, and PDF receipts all read the active brand profile. Voyage / platform branding is removed from these surfaces. The footer still includes legally-required links (privacy + ToS) but with the tenant's own copy.
-
Tenant rolls back if needed.
Brand profile history shows previous versions. Revert restores the prior profile as active (creates a new active row from the historical content — never modifies historical rows). Audit log captures the revert.
Failure modes
White-label disabled by plan
Trigger: tenant on free tier (no white-label flag) opens Settings → Branding.
The Branding section either hides entirely OR shows a "Upgrade for white labeling" CTA with a link to plan upgrade. Server-side: any POST to brand_profiles returns 402 PAYMENT_REQUIRED (or 403 FEATURE_GATED). Harness: stub plan without flag, attempt save, assert 402.
Permission denied — non-tenant-admin role
Trigger: event-organizer (no tenant-admin role) tries to access Branding.
Branding settings are tenant-scoped (admin level), not event-scoped. Server returns 403. UI hides the menu item. Harness: stub event-organizer role, navigate to settings/branding, assert 403, assert no menu link.
Logo upload — file too large or wrong type
Trigger: tenant uploads a 10MB JPG (or a .pdf renamed to .png).
Client-side validation: type check via MIME + magic-byte check, size cap 2MB. Server-side validation re-runs (never trust client). Failure surfaces inline with "PNG or SVG up to 2MB" message. Harness: dispatch oversized + wrong-type, assert both rejected before R2 upload.
SVG with embedded script
Trigger: SVG with <script> tags or onload attributes uploaded as logo.
Server-side SVG sanitizer (allow-list of elements + attributes only) runs before R2 storage. Scripts, foreign-object, external references, and event handlers are stripped. If the resulting sanitized SVG differs in visible content, surface a warning but accept. Harness: upload SVG with script tag, assert stored SVG has no script.
Color contrast fails accessibility
Trigger: tenant picks brand color #FFFF00 (yellow) on white background; text becomes unreadable.
Color picker validates contrast against background (4.5:1 for body text, 3:1 for large text). Failure surfaces inline with "This color may not be accessible. Consider a darker shade or higher contrast." Soft warning — operator can override (some legal/legacy logos exist), but the override is captured in the audit log. Harness: stub low-contrast color, assert warning visible; override + save, assert audit row notes override.
Custom font load failure
Trigger: tenant configures a custom font from a self-hosted URL; the URL becomes unreachable.
Public surface includes a font-display: swap fallback to a web-safe alternative. Render never blocks on font load. The brand profile UI shows a "Font URL is unreachable" warning when detected (background check on save + periodic poll). Harness: stub font URL 404, public page renders with fallback font without layout shift.
Cross-tenant asset URL leak
Trigger: tenant A's logo URL on R2 is guessable / shared CDN; tenant B can hot-link tenant A's logo.
R2 asset paths are tenant-scoped with HMAC-signed URLs. Public consumption goes through a per-event signed URL that's valid only in the context of an event the asset is configured for. Direct R2 access is denied. Harness: forge a cross-tenant R2 URL, assert 403; load same asset via correctly-scoped public URL, assert 200.
Brand profile drift between event publish and current
Trigger: tenant published an event with brand v2; later updates to v3. v2 events should keep their original branding (don't retroactively re-brand).
Each event's published page references brand_profile_version_id, not "current brand profile." When tenant publishes a brand update, existing events keep their reference. Tenant can opt-in to "Apply v3 to all events" via a separate explicit action that's logged. Harness: stub publish at v2, update brand to v3, assert event still renders v2.
Footer rich-text contains a script attempt
Trigger: tenant pastes HTML with <script> or javascript: URL in footer.
ui-rich-text-editor's schema (PREDICATES.md §usesIntegration: irrelevant; this is the rich-text-editor schema) excludes script and javascript:. Paste sanitizer strips those and surfaces a "Some formatting was removed" toast. Harness: paste malicious HTML, assert stored content is sanitized.
Audit log on every brand change
Trigger: tenant toggles between brand profiles or saves a new one.
Every save / activate / revert writes an audit row with operator_id, before_profile_id, after_profile_id, timestamp. Operators can review brand history (uses ui-data-table). Harness: 3 saves, assert 3 audit rows.
Sender-domain enforcement
Trigger: tenant configures a sender-domain that isn't yet SPF/DKIM-verified (depends on EF-060).
Save accepts the domain string but marks status=pending_verification. Emails do NOT send from this domain until verified — they fall back to a tenant-scoped default sending domain ("hello@[tenant-slug].voyage.example") with a footer note "Sent on behalf of [tenant name]." Harness: stub unverified domain, send a test email, assert it sends from default domain.
Trial / downgrade clawback
Trigger: tenant on white-label trial expires (or downgrades). Active brand profile should... what?
Brand profile is preserved (no data destruction on plan change). Public rendering reverts to default (with platform branding visible) within 24h of trial expiry. Tenant can re-enable by upgrading. Harness: stub trial expiry, assert public events show platform branding; re-enable plan, assert custom brand returns.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
branding-settings | Settings → Branding admin | Tenant-admin only |
branding-feature-gate | Visible if plan lacks white-label | "Upgrade for white labeling" CTA |
branding-logo-upload | Logo upload region | Inherits ui-file-uploader |
branding-color-picker | Brand + accent color | With contrast warning |
branding-color-contrast-warning | Visible on low-contrast | Soft warning + override capture |
branding-font-config | Typography | Allow-list + custom URL |
branding-footer-editor | Footer rich text | ui-rich-text-editor with restricted schema |
branding-sender-domain | Sender domain config | References EF-060 verification |
branding-sender-domain-status | Status pill | verified | pending_verification | failed |
branding-legal-links | Privacy + ToS URLs | Allow-list validation |
branding-history-list | Brand profile history | ui-data-table |
branding-revert-button | Per-history-row revert | Uses ui-destructive-confirmation |
branding-apply-to-all-events | Explicit retro-apply CTA | Confirmation modal |
branding-public-surface | Public event page | Renders active brand |
Agent test plan
Probe list
- white-label-feature-gate: free tier, branding-feature-gate visible, save returns 402
- non-tenant-admin-403: event-organizer attempts save, 403
- logo-upload-rejects-oversize: 10MB upload, rejection inline before R2
- logo-upload-rejects-wrong-type: PDF renamed .png, magic-byte check rejects
- svg-strips-scripts: SVG with script, stored SVG has no script
- color-contrast-warning: low-contrast color, warning visible
- color-override-audited: low-contrast + override, audit row notes override
- font-fallback-on-load-failure: stub font 404, public page renders fallback, no layout shift
- cross-tenant-asset-url-403: forged R2 URL, 403
- brand-profile-version-pinning: publish at v2, update brand to v3, v2 events still render v2
- footer-script-stripped: paste malicious HTML, stored content sanitized
- audit-log-brand-changes: every save/activate/revert writes audit row
- sender-domain-pending-falls-back: unverified domain test send uses default
- trial-expiry-falls-back-to-default: stub expiry, public events show platform branding
- trial-restore-on-upgrade: re-enable plan, custom brand returns
- legal-links-allow-list: invalid URL, validation error