1package favicon
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "testing"
11
12 appport "github.com/bnema/dumber/internal/application/port"
13 domainfavicon "github.com/bnema/dumber/internal/domain/favicon"
14)
15
16func TestFetcherExplicitIconURLAndDuckDuckGoFallback(t *testing.T) {
17 png := testPNG(t, 8, 8)
18 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
19 w.Header().Set("Content-Type", "image/png")
20 _, _ = w.Write(png)
21 }))
22 defer srv.Close()
23 fetcher := NewFetcherWithClient(srv.Client(), srv.URL+"/%s.ico")
24
25 got, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: srv.URL + "/page", IconURL: srv.URL + "/icon.png"})
26 if err != nil || got.ContentType != "image/png" || len(got.Bytes) == 0 {
27 t.Fatalf("explicit got=%+v err=%v", got, err)
28 }
29 got, err = fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com/page"})
30 if err != nil || got.Source == "" || len(got.Bytes) == 0 {
31 t.Fatalf("fallback got=%+v err=%v", got, err)
32 }
33}
34
35func TestFetcherResolveURLUsesHostKeyForDuckDuckGoFallback(t *testing.T) {
36 fetcher := NewFetcherWithClient(http.DefaultClient, "https://icons.example/%s.ico")
37
38 got, resolvedKey, source, err := fetcher.resolveURL(appport.FaviconFetchRequest{PageURL: "https://github.com/bnema/gordon/pull/123"})
39 if err != nil {
40 t.Fatal(err)
41 }
42 if source != domainfavicon.SourceDuckDuckGo {
43 t.Fatalf("source = %q, want %q", source, domainfavicon.SourceDuckDuckGo)
44 }
45 if resolvedKey != "github.com" {
46 t.Fatalf("resolvedKey = %q, want github.com", resolvedKey)
47 }
48 if got != "https://icons.example/github.com.ico" {
49 t.Fatalf("duckduckgo URL = %q, want host-based fallback URL", got)
50 }
51}
52
53func TestFetcherMissesAndGuards(t *testing.T) {
54 fetcher := NewFetcher()
55 if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com", IconURL: "file:///x"}); !errors.Is(err, appport.ErrFaviconMiss) {
56 t.Fatalf("scheme err=%v", err)
57 }
58 if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com", IconURL: "http://127.0.0.1/icon.png"}); !errors.Is(err, appport.ErrFaviconMiss) {
59 t.Fatalf("ssrf err=%v", err)
60 }
61
62 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusNotFound) }))
63 defer srv.Close()
64 fetcher = NewFetcherWithClient(srv.Client(), "")
65 if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: srv.URL, IconURL: srv.URL + "/missing"}); !errors.Is(err, appport.ErrFaviconMiss) {
66 t.Fatalf("404 err=%v", err)
67 }
68}
69
70func TestFetcherRejectsMismatchedDeclaredContentType(t *testing.T) {
71 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
72 w.Header().Set("Content-Type", "image/png")
73 _, _ = w.Write([]byte("<html>not a png</html>"))
74 }))
75 defer srv.Close()
76 fetcher := NewFetcherWithClient(srv.Client(), "")
77 if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: srv.URL, IconURL: srv.URL + "/icon.png"}); !errors.Is(err, appport.ErrFaviconMiss) {
78 t.Fatalf("mismatched content err=%v", err)
79 }
80}
81
82func TestFetcherLocalhostExceptionRequiresSameOrigin(t *testing.T) {
83 page := mustParseURL(t, "http://localhost:3000/page")
84 same := mustParseURL(t, "http://localhost:3000/favicon.ico")
85 otherPort := mustParseURL(t, "http://localhost:8080/favicon.ico")
86 otherScheme := mustParseURL(t, "https://localhost:3000/favicon.ico")
87 defaultPortPage := mustParseURL(t, "http://localhost/page")
88 defaultPortIcon := mustParseURL(t, "http://localhost:80/favicon.ico")
89 if !allowedURLForPage(page.String(), same) {
90 t.Fatal("same-origin localhost should be allowed")
91 }
92 if allowedURLForPage(page.String(), otherPort) {
93 t.Fatal("different localhost port should be rejected")
94 }
95 if allowedURLForPage(page.String(), otherScheme) {
96 t.Fatal("different localhost scheme should be rejected")
97 }
98 if !allowedURLForPage(defaultPortPage.String(), defaultPortIcon) {
99 t.Fatal("default localhost port should normalize as same-origin")
100 }
101 if got := allowedLocalOrigin(defaultPortPage.String()); got != "localhost:80" {
102 t.Fatalf("allowedLocalOrigin default port = %q, want localhost:80", got)
103 }
104 if !allowedURLForOrigin(context.WithValue(context.Background(), allowedLocalOriginKey, allowedLocalOrigin(defaultPortPage.String())), mustParseURL(t, "http://localhost/favicon.ico")) {
105 t.Fatal("same-origin localhost redirect with implicit default port should be allowed")
106 }
107}
108
109func mustParseURL(t *testing.T, raw string) *url.URL {
110 t.Helper()
111 u, err := url.Parse(raw)
112 if err != nil {
113 t.Fatal(err)
114 }
115 return u
116}
117
118func TestFetcherMaxSizeAndRedirectLimit(t *testing.T) {
119 big := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
120 w.Header().Set("Content-Type", "image/png")
121 _, _ = w.Write(make([]byte, 20))
122 }))
123 defer big.Close()
124 fetcher := NewFetcherWithClient(big.Client(), "")
125 fetcher.maxBytes = 4
126 if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: big.URL, IconURL: big.URL}); err == nil {
127 t.Fatal("expected max size error")
128 }
129
130 redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131 http.Redirect(w, r, fmt.Sprintf("/r%s", r.URL.Path), http.StatusFound)
132 }))
133 defer redir.Close()
134 fetcher = NewFetcherWithClient(redir.Client(), "")
135 if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: redir.URL, IconURL: redir.URL + "/r"}); !errors.Is(err, appport.ErrFaviconMiss) {
136 t.Fatalf("redirect err=%v", err)
137 }
138}