package art import ( "bytes" "context" "errors" "fmt" "image" "image/color" "image/png" "net" "net/http" "net/http/httptest" "os" "os/exec" "strings" "testing" ) var ctx = context.Background() func pngBytes(w, h int) []byte { img := image.NewRGBA(image.Rect(0, 0, w, h)) for x := 0; x < w; x++ { img.Set(x, 0, color.RGBA{200, 0, 0, 255}) } var b bytes.Buffer png.Encode(&b, img) return b.Bytes() } // fakeSources serves canned answers for every source. func fakeSources(t *testing.T, failDeezer bool) *Client { t.Helper() mux := http.NewServeMux() var base string mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { // iTunes fmt.Fprintf(w, `{"results":[{"artistName":"Avenged Sevenfold","collectionName":"Nightmare","artworkUrl100":"%s/img/100x100bb.jpg"}]}`, base) }) mux.HandleFunc("/search/album", func(w http.ResponseWriter, r *http.Request) { // Deezer if failDeezer { http.Error(w, "down", 500) return } fmt.Fprintf(w, `{"data":[{"title":"Nightmare","cover_xl":"%s/dz/xl.jpg","cover_medium":"%s/dz/m.jpg","artist":{"name":"Avenged Sevenfold"}}]}`, base, base) }) mux.HandleFunc("/ws/2/release-group/", func(w http.ResponseWriter, r *http.Request) { // MusicBrainz fmt.Fprint(w, `{"release-groups":[{"id":"sq","title":"Nightmare","artist-credit":[{"name":"Avenged Sevenfold"}]}, {"id":"wide","title":"Nightmare (Live)","artist-credit":[{"name":"Avenged Sevenfold"}]},{"id":"none","title":"X"}]}`) }) mux.HandleFunc("/release-group/sq/front-250", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/png") w.Write(pngBytes(250, 250)) }) mux.HandleFunc("/release-group/wide/front-250", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/png") w.Write(pngBytes(250, 180)) }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // DDG page with token if r.URL.Path == "/" { fmt.Fprint(w, ``) return } http.NotFound(w, r) }) mux.HandleFunc("/i.js", func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("vqd") != "4-12345" || !strings.Contains(r.URL.Query().Get("f"), "layout:Square") { http.Error(w, "bad", 400) return } fmt.Fprint(w, `{"results":[{"title":"near square","image":"https://x/a.jpg","thumbnail":"t","width":600,"height":594}, {"title":"square","image":"https://x/b.jpg","thumbnail":"t","width":800,"height":800}, {"title":"tiny","image":"https://x/c.jpg","thumbnail":"t","width":200,"height":200}]}`) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) base = srv.URL c := New("test") c.HTTP = srv.Client() // the safe client refuses loopback, which the fakes use c.ITunesURL, c.DeezerURL, c.MusicBrainzURL, c.CAAURL, c.DDGURL, c.GoogleURL = base, base, base, base, base, base return c } func TestSearchSquareOnly(t *testing.T) { c := fakeSources(t, false) res, errs := c.Search(ctx, Query{Artist: "Avenged Sevenfold", Album: "Nightmare"}, Config{Sources: AllSources}) if len(errs) != 0 { t.Fatalf("errors: %v", errs) } got := map[string]Result{} for _, r := range res { got[r.Source+":"+r.Title] = r } for _, want := range []string{"itunes:Nightmare", "deezer:Nightmare", "musicbrainz:Nightmare", "duckduckgo:square"} { if _, ok := got[want]; !ok { t.Errorf("missing %s in %+v", want, res) } } for _, not := range []string{"duckduckgo:near square", "duckduckgo:tiny", "musicbrainz:Nightmare (Live)"} { if _, ok := got[not]; ok { t.Errorf("%s must be filtered out (not square or too small)", not) } } if r := got["itunes:Nightmare"]; !strings.HasSuffix(r.URL, "/img/1000x1000bb.jpg") || res[0].Source != ITunes { t.Errorf("iTunes should come first at 1000px: %+v", r) } for _, r := range res { if r.Source == Google { t.Error("Google must not be queried without an API key") } } } func TestOneFailingSourceDoesNotHideOthers(t *testing.T) { c := fakeSources(t, true) res, errs := c.Search(ctx, Query{Artist: "A", Album: "B"}, Config{Sources: []string{ITunes, Deezer}}) if errs[Deezer] == "" || len(res) != 1 || res[0].Source != ITunes { t.Fatalf("want iTunes result plus a Deezer error, got %+v %v", res, errs) } } func TestSquare(t *testing.T) { for _, c := range []struct { w, h int want bool }{{1000, 1000, true}, {1000, 996, true}, {600, 594, false}, {500, 498, true}, {0, 0, false}, {1200, 1100, false}} { if Square(c.w, c.h) != c.want { t.Errorf("Square(%d,%d) != %v", c.w, c.h, c.want) } } } func TestSafeFetch(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/page" { w.Header().Set("Content-Type", "text/html") fmt.Fprint(w, "") return } w.Header().Set("Content-Type", "image/png") w.Write(pngBytes(10, 10)) })) defer srv.Close() c := New("test") // the real, safe client if _, err := c.FetchImage(ctx, srv.URL+"/img.png"); !errors.Is(err, ErrBlockedAddress) { t.Fatalf("loopback must be refused, got %v", err) } for _, u := range []string{"file:///etc/passwd", "ftp://x/y.png", "gopher://x", "not a url"} { if _, err := c.FetchImage(ctx, u); err == nil { t.Errorf("%q must be refused", u) } } c.HTTP = srv.Client() if _, err := c.FetchImage(ctx, srv.URL+"/page"); err == nil || !strings.Contains(err.Error(), "not an image") { t.Fatalf("non-images must be refused, got %v", err) } if b, err := c.FetchImage(ctx, srv.URL+"/img.png"); err != nil || len(b) == 0 { t.Fatalf("image fetch: %v", err) } for _, ip := range []string{"10.0.0.1", "192.168.1.1", "172.16.0.1", "169.254.169.254", "100.64.0.1", "::1", "fc00::1", "0.0.0.0"} { if !blockedIP(parseIP(ip)) { t.Errorf("%s should be blocked", ip) } } if blockedIP(parseIP("1.1.1.1")) { t.Error("public addresses are allowed") } } func TestPrepare(t *testing.T) { ff, err := exec.LookPath("ffmpeg") if err != nil { t.Skip("ffmpeg not installed") } s := &Store{Dir: t.TempDir(), FFmpeg: ff} p, err := s.Prepare(ctx, pngBytes(1600, 1600), 1000) if err != nil { t.Fatal(err) } if p.Width != 1000 || p.Height != 1000 || !p.Square { t.Fatalf("want 1000x1000 square, got %+v", p) } path, err := s.Path(p.Token) if err != nil { t.Fatal(err) } b, _ := os.ReadFile(path) if len(b) < 3 || b[0] != 0xFF || b[1] != 0xD8 { t.Fatal("prepared image should be a JPEG") } if small, _ := s.Prepare(ctx, pngBytes(300, 300), 1000); small.Width != 300 { t.Fatal("images are never enlarged") } if _, err := s.Prepare(ctx, []byte("not an image"), 1000); err == nil { t.Fatal("garbage must be refused") } for _, bad := range []string{"../etc", "abc", strings.Repeat("g", 32)} { if _, err := s.Path(bad); !errors.Is(err, ErrBadToken) { t.Errorf("token %q must be refused", bad) } } } func parseIP(s string) net.IP { return net.ParseIP(s) }