Files
silo-server/internal/server/branding_frontend_test.go
T
5afe56cfc0 feat(jellycompat): add runtime-managed Jellyfin Web compatibility (#77)
* feat(jellycompat): install web assets at runtime

* fix(jellycompat): recover stale web operation locks

* fix(jellycompat): harden web component management

* feat(admin): refine compat settings and restart status

* chore(dev): add hot-reload docker compose stack

* fix(dev): include npm in hot-reload backend

* feat(admin): refine Jellyfin compatibility settings

* feat(settings): improve jellyfin proxy summary

* feat(settings): improve jellyfin web controls

* fix(settings): update jellyfin web removal status

* fix(settings): enable jellyfin web after install

* feat(jellycompat): auto-select web ui version

* test(api): update rate limit handler setup

* feat(jellycompat): refine web ui install onboarding

* fix(jellycompat): address web ui install review issues

* fix(onboarding): mirror jellyfin api runtime status

* fix(admin): remove global restart banner

* fix(settings): gate restart required tracking

* fix(jellyfin): ignore live settings for restart status

* fix(jellyfin): avoid restart for live compat settings

* fix(subtitles): normalize AI language codes

* fix(catalog): support partial title search tokens

* feat(branding): add white-label customization

* Add push relay engineering plan

- Document relay API contracts, APNs/FCM behavior, auth, storage, and ops
- Capture implementation plan, provider references, decisions, and README

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-15 09:34:08 -04:00

117 lines
4.3 KiB
Go

package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"github.com/Silo-Server/silo-server/internal/branding"
"github.com/Silo-Server/silo-server/internal/s3client"
)
// fakeSettings is an in-memory branding.SettingsStore.
type fakeSettings map[string]string
func (f fakeSettings) Get(_ context.Context, key string) (string, error) { return f[key], nil }
func (f fakeSettings) Set(_ context.Context, key, value string) error { f[key] = value; return nil }
// fakeAssetStore is an in-memory branding.AssetStore.
type fakeAssetStore struct{ data map[string][]byte }
func (f *fakeAssetStore) PutObject(_ context.Context, _, key string, data []byte) error {
f.data[key] = data
return nil
}
func (f *fakeAssetStore) GetObject(_ context.Context, _, key string) ([]byte, error) {
if d, ok := f.data[key]; ok {
return d, nil
}
return nil, s3client.ErrNotFound
}
func (f *fakeAssetStore) Bucket() string { return "test" }
func withBranding(t *testing.T, settings fakeSettings) {
t.Helper()
prevFS, prevBranding := WebDistFS, Branding
WebDistFS = fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte(
`<!doctype html><head><title>Silo</title>` +
`<link rel="icon" href="/favicon.ico" sizes="any" /></head><body></body>`)},
"favicon.ico": &fstest.MapFile{Data: []byte("STATIC_ICO")},
}
Branding = branding.NewService(settings, nil) // no S3: text branding only
t.Cleanup(func() { WebDistFS, Branding = prevFS, prevBranding })
}
func TestFrontendInjectsServerNameIntoTitle(t *testing.T) {
withBranding(t, fakeSettings{branding.KeyServerName: "Acme Media"})
rr := httptest.NewRecorder()
FrontendHandler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
if !strings.Contains(rr.Body.String(), "<title>Acme Media</title>") {
t.Fatalf("title not branded: %q", rr.Body.String())
}
// CSP must still be applied to the templated shell.
if rr.Header().Get("Content-Security-Policy") != frontendContentSecurityPolicy {
t.Fatalf("CSP missing on branded index.html")
}
}
func TestFrontendServesDynamicManifest(t *testing.T) {
withBranding(t, fakeSettings{branding.KeyServerName: "Acme Media"})
rr := httptest.NewRecorder()
FrontendHandler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/site.webmanifest", nil))
if ct := rr.Header().Get("Content-Type"); ct != "application/manifest+json" {
t.Fatalf("manifest content-type = %q", ct)
}
var m map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &m); err != nil {
t.Fatalf("manifest not valid JSON: %v", err)
}
if m["name"] != "Acme Media" {
t.Fatalf("manifest name = %v", m["name"])
}
}
func TestFrontendFaviconFallsThroughWhenNoCustom(t *testing.T) {
withBranding(t, fakeSettings{}) // no custom favicon configured
rr := httptest.NewRecorder()
FrontendHandler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/favicon.ico", nil))
if rr.Body.String() != "STATIC_ICO" {
t.Fatalf("expected bundled favicon fallthrough, got %q", rr.Body.String())
}
}
// TestFrontendCustomSvgFaviconIsHardened guards the stored-XSS mitigation: an
// admin-uploaded SVG favicon must be served with nosniff + a sandboxing CSP so
// it cannot execute scripts when navigated to directly on the app origin.
func TestFrontendCustomSvgFaviconIsHardened(t *testing.T) {
store := &fakeAssetStore{data: map[string][]byte{"branding/favicon/abc.svg": []byte("<svg/>")}}
prevFS, prevBranding := WebDistFS, Branding
WebDistFS = fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<title>Silo</title>")},
"favicon.ico": &fstest.MapFile{Data: []byte("STATIC_ICO")},
}
Branding = branding.NewService(fakeSettings{"branding.favicon_ref": "abc.svg"}, store)
t.Cleanup(func() { WebDistFS, Branding = prevFS, prevBranding })
rr := httptest.NewRecorder()
FrontendHandler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/favicon.ico", nil))
if rr.Code != http.StatusOK || rr.Body.String() != "<svg/>" {
t.Fatalf("expected custom svg favicon, got status=%d body=%q", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Content-Security-Policy"); got != branding.AssetContentSecurityPolicy {
t.Fatalf("favicon CSP = %q, want sandboxing policy", got)
}
if got := rr.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("favicon nosniff = %q", got)
}
}