Files
3x-ui/internal/web/service/panel/sponsor_test.go
T
MHSanaei 878d37c2fc fix(panel): bound the sponsor logo cache and evict logos that left the list
The logo cache was keyed by whatever names the sponsors list named and
never pruned, so a list naming thousands of logos made the public logo
route pin hundreds of megabytes, and every hourly refresh with new
names added more.

Only the first 32 distinct logo names of the active list are kept,
surplus sponsors render with their initial as a bad logo name already
does, and each store drops cached logos no longer on the list.
2026-11-01 09:03:42 +01:00

344 lines
12 KiB
Go

package panel
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
)
var sponsorTestNow = time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC)
func validSponsor() Sponsor {
return Sponsor{
ID: "acme",
Name: "Acme",
Slots: []string{"dashboard"},
Until: sponsorTestNow.Add(24 * time.Hour),
Logo: "acme.png",
Link: "https://acme.example/",
}
}
func TestActiveSponsorsFilters(t *testing.T) {
cases := []struct {
name string
mutate func(*Sponsor)
kept bool
}{
{"valid", func(*Sponsor) {}, true},
{"expired", func(s *Sponsor) { s.Until = sponsorTestNow }, false},
{"not started yet", func(s *Sponsor) { s.From = new(sponsorTestNow.Add(time.Hour)) }, false},
{"starts exactly now", func(s *Sponsor) { s.From = new(sponsorTestNow) }, true},
{"enable false with future until", func(s *Sponsor) { s.Enable = new(false) }, false},
{"enable true without until", func(s *Sponsor) { s.Enable, s.Until = new(true), time.Time{} }, false},
{"enable true with future until", func(s *Sponsor) { s.Enable = new(true) }, true},
{"missing id", func(s *Sponsor) { s.ID = "" }, false},
{"http link", func(s *Sponsor) { s.Link = "http://acme.example/" }, false},
{"javascript link", func(s *Sponsor) { s.Link = "javascript:alert(1)" }, false},
{"only unknown slots", func(s *Sponsor) { s.Slots = []string{"subpage"} }, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sp := validSponsor()
tc.mutate(&sp)
got := activeSponsors(&SponsorList{Sponsors: []Sponsor{sp}}, sponsorTestNow)
if kept := len(got.Sponsors) == 1; kept != tc.kept {
t.Fatalf("kept = %v, want %v", kept, tc.kept)
}
})
}
}
func TestActiveSponsorsLogoName(t *testing.T) {
cases := []struct{ logo, want string }{
{"acme.png", "/sponsors/logo/acme.png"},
{"VPS.png", "/sponsors/logo/VPS.png"},
{"../x.png", ""},
{"https://evil.example/x.png", ""},
{"..png", ""},
{"logo.svg", ""},
{"", ""},
}
for _, tc := range cases {
t.Run(tc.logo, func(t *testing.T) {
sp := validSponsor()
sp.Logo = tc.logo
got := activeSponsors(&SponsorList{Sponsors: []Sponsor{sp}}, sponsorTestNow)
if len(got.Sponsors) != 1 {
t.Fatalf("sponsor dropped for logo %q; want it kept", tc.logo)
}
if got.Sponsors[0].Logo != tc.want {
t.Errorf("logo = %q, want %q", got.Sponsors[0].Logo, tc.want)
}
})
}
}
func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) {
sp := validSponsor()
sp.Slots = []string{"subpage", "sidebar", "login"}
got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow)
if len(got.Sponsors) != 1 {
t.Fatalf("got %d sponsors, want 1", len(got.Sponsors))
}
if want := "/sponsors/logo/acme.png"; got.Sponsors[0].Logo != want {
t.Errorf("logo = %q, want %q", got.Sponsors[0].Logo, want)
}
if s := got.Sponsors[0].Slots; len(s) != 1 || s[0] != "login" {
t.Errorf("slots = %v, want [login]", s)
}
if got.Contact != "" {
t.Errorf("contact = %q, want empty for non-https", got.Contact)
}
}
func setupSponsorServer(t *testing.T, body string) *atomic.Int32 {
t.Helper()
t.Setenv("XUI_DB_FOLDER", t.TempDir())
dbtest.InitDB(t, config.GetDBPath())
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
isLogo := strings.HasPrefix(r.URL.Path, "/media/")
if (isLogo && failSponsorLogos.Load()) || (!isLogo && failSponsorList.Load()) {
w.WriteHeader(http.StatusBadGateway)
return
}
if strings.HasPrefix(r.URL.Path, "/media/many") {
_, _ = w.Write(pngMagic)
return
}
switch r.URL.Path {
case "/media/acme.png":
_, _ = w.Write(pngMagic)
case "/media/fake.png":
_, _ = w.Write([]byte("<svg onload=alert(1)>"))
default:
_, _ = w.Write([]byte(body))
}
}))
t.Cleanup(srv.Close)
prevURL, prevBase, prevNow := sponsorsURL, sponsorLogoBase, sponsorNow
sponsorsURL, sponsorLogoBase = srv.URL+"/sponsors.json", srv.URL+"/media/"
resetSponsorCache()
t.Cleanup(func() {
sponsorsURL, sponsorLogoBase, sponsorNow = prevURL, prevBase, prevNow
failSponsorList.Store(false)
failSponsorLogos.Store(false)
resetSponsorCache()
})
return &hits
}
var failSponsorList, failSponsorLogos atomic.Bool
func TestGetSponsorsKeepsLastListWhenRefreshFails(t *testing.T) {
hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"],
"until":"2099-01-01T00:00:00Z","link":"https://acme.example/"}]}`)
now := sponsorTestNow
sponsorNow = func() time.Time { return now }
svc := &PanelService{}
if got, err := svc.GetSponsors(); err != nil || len(got.Sponsors) != 1 {
t.Fatalf("first call = %+v, %v; want 1 sponsor", got, err)
}
failSponsorList.Store(true)
now = now.Add(sponsorsTTL)
got, err := svc.GetSponsors()
if err != nil || len(got.Sponsors) != 1 || got.Sponsors[0].ID != "acme" {
t.Fatalf("after failed refresh = %+v, %v; want the last good sponsor kept", got, err)
}
now = now.Add(sponsorsErrTTL - time.Second)
if _, err := svc.GetSponsors(); err != nil {
t.Fatal(err)
}
if n := hits.Load(); n != 2 {
t.Fatalf("remote hits = %d, want 2 (failed refresh retried only after sponsorsErrTTL)", n)
}
}
func TestGetSponsorLogoCachesFailuresAndKeepsLastImage(t *testing.T) {
hits := setupSponsorServer(t, `{"sponsors":[
{"id":"acme","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/","logo":"acme.png"},
{"id":"fake","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://f.example/","logo":"fake.png"}]}`)
now := sponsorTestNow
sponsorNow = func() time.Time { return now }
svc := &PanelService{}
const wantErr = "sponsor logo fake.png has content type text/plain; charset=utf-8"
for range 2 {
if _, _, err := svc.GetSponsorLogo("fake.png"); err == nil || err.Error() != wantErr {
t.Fatalf("fake.png err = %v, want %q", err, wantErr)
}
}
if n := hits.Load(); n != 2 {
t.Fatalf("remote hits = %d, want 2 (list + one fake.png fetch; the failure must be cached)", n)
}
if _, _, err := svc.GetSponsorLogo("acme.png"); err != nil {
t.Fatal(err)
}
failSponsorLogos.Store(true)
now = now.Add(sponsorsTTL)
data, ctype, err := svc.GetSponsorLogo("acme.png")
if err != nil || ctype != "image/png" || string(data) != string(pngMagic) {
t.Fatalf("acme.png after failed refresh = %q, %q, %v; want the last good image", data, ctype, err)
}
}
func resetSponsorCache() {
sponsorsMu.Lock()
defer sponsorsMu.Unlock()
sponsorsRaw, sponsorsErr, sponsorsRetryAt = nil, nil, time.Time{}
logosMu.Lock()
defer logosMu.Unlock()
logos = map[string]sponsorLogo{}
}
var pngMagic = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
func TestGetSponsorLogo(t *testing.T) {
hits := setupSponsorServer(t, `{"sponsors":[
{"id":"acme","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/","logo":"acme.png"},
{"id":"fake","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://f.example/","logo":"fake.png"},
{"id":"old","slots":["page"],"until":"2000-01-01T00:00:00Z","link":"https://o.example/","logo":"old.png"}]}`)
svc := &PanelService{}
data, ctype, err := svc.GetSponsorLogo("acme.png")
if err != nil || ctype != "image/png" || string(data) != string(pngMagic) {
t.Fatalf("acme.png = %q, %q, %v; want png bytes", data, ctype, err)
}
before := hits.Load()
if _, _, err := svc.GetSponsorLogo("acme.png"); err != nil {
t.Fatal(err)
}
if hits.Load() != before {
t.Fatalf("second logo fetch hit the remote; want cached")
}
for _, name := range []string{"old.png", "other.png", "../sponsors.json"} {
if _, _, err := svc.GetSponsorLogo(name); !errors.Is(err, ErrSponsorLogoUnknown) {
t.Errorf("%s: err = %v, want ErrSponsorLogoUnknown", name, err)
}
}
_, _, err = svc.GetSponsorLogo("fake.png")
if want := "sponsor logo fake.png has content type text/plain; charset=utf-8"; err == nil || err.Error() != want {
t.Errorf("fake.png: err = %v, want %q", err, want)
}
}
// The feed decides which logo names exist, so it must not decide how many the panel holds in memory.
func TestSponsorLogoCacheStaysBounded(t *testing.T) {
var list []string
for i := range 3 * maxSponsorLogos {
until := "2099-01-01T00:00:00Z"
if i < maxSponsorLogos {
until = sponsorTestNow.Add(time.Minute).Format(time.RFC3339)
}
list = append(list, fmt.Sprintf(`{"id":"s%d","slots":["page"],"until":%q,
"link":"https://s.example/","logo":"many%d.png"}`, i, until, i))
}
setupSponsorServer(t, `{"sponsors":[`+strings.Join(list, ",")+`]}`)
now := sponsorTestNow
sponsorNow = func() time.Time { return now }
svc := &PanelService{}
got, err := svc.GetSponsors()
if err != nil || len(got.Sponsors) != 3*maxSponsorLogos {
t.Fatalf("GetSponsors() = %d sponsors, %v; want all %d kept (only surplus logos drop)", len(got.Sponsors), err, 3*maxSponsorLogos)
}
for _, step := range []string{"full list", "first sponsors expired"} {
served := 0
for i := range 3 * maxSponsorLogos {
if _, _, err := svc.GetSponsorLogo(fmt.Sprintf("many%d.png", i)); err == nil {
served++
}
}
if served != maxSponsorLogos || len(logos) != maxSponsorLogos {
t.Errorf("%s: served %d logos, cache holds %d, want %d for both", step, served, len(logos), maxSponsorLogos)
}
now = now.Add(2 * time.Minute)
}
}
func TestGetSponsorsCachesAndExpiresWhileCached(t *testing.T) {
hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"],
"until":"2026-10-15T00:30:00Z","link":"https://acme.example/"}]}`)
now := sponsorTestNow
sponsorNow = func() time.Time { return now }
svc := &PanelService{}
got, err := svc.GetSponsors()
if err != nil || len(got.Sponsors) != 1 {
t.Fatalf("first call = %+v, %v; want 1 sponsor", got, err)
}
now = now.Add(45 * time.Minute)
got, err = svc.GetSponsors()
if err != nil || len(got.Sponsors) != 0 {
t.Fatalf("after until = %+v, %v; want 0 sponsors", got, err)
}
if n := hits.Load(); n != 1 {
t.Fatalf("remote hits = %d, want 1 (cached within TTL)", n)
}
now = now.Add(sponsorsTTL)
if _, err := svc.GetSponsors(); err != nil {
t.Fatal(err)
}
if n := hits.Load(); n != 2 {
t.Fatalf("remote hits after TTL = %d, want 2", n)
}
}
func TestGetSponsorsRejectsOversizeBody(t *testing.T) {
setupSponsorServer(t, `{"contact":"`+strings.Repeat("a", maxSponsorsBytes)+`"}`)
_, err := (&PanelService{}).GetSponsors()
want := sponsorsURL + " exceeds 262144 bytes"
if err == nil || err.Error() != want {
t.Fatalf("err = %v, want %q", err, want)
}
}
func TestGetSponsorsDebugReadsLocalCheckout(t *testing.T) {
hits := setupSponsorServer(t, `{"sponsors":[]}`)
t.Setenv("XUI_DEBUG", "true")
root := t.TempDir()
local := filepath.Join(root, "sponsors", "3X")
if err := os.MkdirAll(local, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "3x-ui"), 0o700); err != nil {
t.Fatal(err)
}
t.Chdir(filepath.Join(root, "3x-ui"))
write := func(id string) {
t.Helper()
body := `{"sponsors":[{"id":"` + id + `","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/"}]}`
if err := os.WriteFile(filepath.Join(local, "sponsors.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
svc := &PanelService{}
for _, id := range []string{"first", "edited"} {
write(id)
got, err := svc.GetSponsors()
if err != nil || len(got.Sponsors) != 1 || got.Sponsors[0].ID != id {
t.Fatalf("GetSponsors() = %+v, %v; want local sponsor %q", got, err, id)
}
}
if n := hits.Load(); n != 0 {
t.Fatalf("remote hits = %d, want 0 in debug mode", n)
}
}