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( `Silo` + ``)}, "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(), "Acme Media") { 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") } } // TestFrontendShellCacheFollowsBrandingChanges guards the rendered-shell // cache: one handler instance must re-render (and re-tag) the shell when the // branding snapshot changes, not keep serving the first rendering forever. func TestFrontendShellCacheFollowsBrandingChanges(t *testing.T) { settings := fakeSettings{branding.KeyServerName: "Acme Media"} withBranding(t, settings) handler := FrontendHandler() first := httptest.NewRecorder() handler.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/", nil)) if !strings.Contains(first.Body.String(), "Acme Media") { t.Fatalf("initial title not branded: %q", first.Body.String()) } // Repeat request with unchanged branding: same ETag (served from cache). repeat := httptest.NewRecorder() handler.ServeHTTP(repeat, httptest.NewRequest(http.MethodGet, "/", nil)) if first.Header().Get("ETag") != repeat.Header().Get("ETag") { t.Fatalf("etag changed without a branding change: %q vs %q", first.Header().Get("ETag"), repeat.Header().Get("ETag")) } settings[branding.KeyServerName] = "Renamed Media" renamed := httptest.NewRecorder() handler.ServeHTTP(renamed, httptest.NewRequest(http.MethodGet, "/", nil)) if !strings.Contains(renamed.Body.String(), "Renamed Media") { t.Fatalf("renamed title not rendered: %q", renamed.Body.String()) } if renamed.Header().Get("ETag") == first.Header().Get("ETag") { t.Fatal("etag must change when the rendered shell changes") } } 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("")}} prevFS, prevBranding := WebDistFS, Branding WebDistFS = fstest.MapFS{ "index.html": &fstest.MapFile{Data: []byte("Silo")}, "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() != "" { 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) } }