* 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>
117 lines
4.3 KiB
Go
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)
|
|
}
|
|
}
|