v0.31.0 bf0756b

fetcher_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close