package httpgetter import ( "context" "errors" "fmt" "io" "net" "net/http" "net/url" "strings" "sync" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/net/html" ) type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } func TestExtractDocumentMetadataPrecedenceAndFallbacks(t *testing.T) { tests := []struct { name string markup string expected HTMLMeta }{ { name: "Open Graph wins field by field", markup: ` Standard title `, expected: HTMLMeta{ Title: "Open Graph title", Description: "Twitter description", Image: "https://example.com/twitter.png", }, }, { name: "JSON-LD graph and image object", markup: ``, expected: HTMLMeta{ Title: "JSON-LD title", Description: "JSON-LD description", Image: "https://example.com/posts/images/cover.jpg", }, }, { name: "nested JSON-LD main entity", markup: ``, expected: HTMLMeta{ Title: "Nested article title", Description: "Nested article description", Image: "https://example.com/nested.png", }, }, { name: "standard metadata", markup: `Standard title `, expected: HTMLMeta{ Title: "Standard title", Description: "Standard description", Image: "https://example.com/standard.png", }, }, { name: "semantic main content", markup: `

Semantic title

This paragraph is deliberately long enough to become the semantic preview description.

`, expected: HTMLMeta{ Title: "Semantic title", Description: "This paragraph is deliberately long enough to become the semantic preview description.", Image: "https://example.com/posts/semantic.png", }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { meta := extractTestMetadata(t, test.markup) require.Equal(t, test.expected, *meta) }) } } func TestExtractJSONLDImageForms(t *testing.T) { tests := []struct { name string image string want string }{ {name: "string", image: `"/one.png"`, want: "https://example.com/one.png"}, {name: "array", image: `["data:image/png;base64,abc", "/two.png"]`, want: "https://example.com/two.png"}, {name: "url object", image: `{"url":"/three.png"}`, want: "https://example.com/three.png"}, {name: "content URL object", image: `{"url":"javascript:alert(1)","contentUrl":"/four.png"}`, want: "https://example.com/four.png"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { markup := `` meta := extractTestMetadata(t, markup) require.Equal(t, test.want, meta.Image) }) } } func TestExtractDocumentMetadataNormalizesAndTruncatesText(t *testing.T) { markup := ` ` meta := extractTestMetadata(t, markup) require.Len(t, []rune(meta.Title), maxTitleRunes) require.NotContains(t, meta.Title, " ") require.True(t, strings.HasSuffix(meta.Title, "…")) require.Len(t, []rune(meta.Description), maxDescriptionRunes) require.True(t, strings.HasSuffix(meta.Description, "…")) } func TestExtractDocumentMetadataSkipsUnsafeImages(t *testing.T) { markup := ` ` meta := extractTestMetadata(t, markup) require.Equal(t, "https://example.com/safe.png", meta.Image) } func TestHTMLMetaFetcherOEmbed(t *testing.T) { var requests atomic.Int32 fetcher := newTestFetcher(roundTripFunc(func(req *http.Request) (*http.Response, error) { requests.Add(1) switch req.URL.Path { case "/post": return htmlResponse(req, ` `), nil case "/oembed": assert.Contains(t, req.Header.Get("Accept"), "application/json+oembed") return response(req, http.StatusOK, "application/json+oembed", `{ "type":"rich","title":"oEmbed title","description":"oEmbed description", "thumbnail_url":"/oembed.png","html":"" }`), nil default: return nil, errors.New("unexpected request path: " + req.URL.Path) } })) meta, err := fetcher.Get(context.Background(), "http://93.184.216.34/post") require.NoError(t, err) require.Equal(t, &HTMLMeta{ Title: "oEmbed title", Description: "oEmbed description", Image: "http://93.184.216.34/oembed.png", }, meta) require.EqualValues(t, 2, requests.Load()) } func TestHTMLMetaFetcherIgnoresInvalidOEmbed(t *testing.T) { tests := []struct { name string endpoint string contentType string body string wantCalls int32 }{ {name: "unsupported content type", endpoint: "/oembed", contentType: "text/html", body: `

not JSON

`, wantCalls: 2}, {name: "oversized response", endpoint: "/oembed", contentType: "application/json", body: strings.Repeat("x", maxOEmbedBytes+1), wantCalls: 2}, {name: "unsafe endpoint", endpoint: "http://127.0.0.1/oembed", contentType: "application/json", body: `{}`, wantCalls: 1}, {name: "XML discovery", endpoint: "/oembed", contentType: "application/json", body: `{}`, wantCalls: 1}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { var calls atomic.Int32 fetcher := newTestFetcher(roundTripFunc(func(req *http.Request) (*http.Response, error) { call := calls.Add(1) if call == 1 { discoveryType := "application/json+oembed" if test.name == "XML discovery" { discoveryType = "text/xml+oembed" } return htmlResponse(req, ` `), nil } return response(req, http.StatusOK, test.contentType, test.body), nil })) meta, err := fetcher.Get(context.Background(), "http://93.184.216.34/post") require.NoError(t, err) require.Equal(t, "Fallback title", meta.Title) require.Equal(t, test.wantCalls, calls.Load()) }) } } func TestHTMLMetaFetcherBotAwareSPA(t *testing.T) { transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { assert.Contains(t, req.Header.Get("Accept"), "text/html") if req.Header.Get("User-Agent") == defaultLinkPreviewUserAgent { return htmlResponse(req, ` `), nil } return htmlResponse(req, `Application shell
`), nil }) fetcher := newTestFetcher(transport) meta, err := fetcher.Get(context.Background(), "http://93.184.216.34/spa") require.NoError(t, err) require.Equal(t, "Rich SPA title", meta.Title) require.Equal(t, "Prerendered for MemosBot", meta.Description) } func TestHTMLMetaFetcherDefaultHeadersAndDeadline(t *testing.T) { fetcher := newTestFetcher(roundTripFunc(func(req *http.Request) (*http.Response, error) { assert.Equal(t, defaultLinkPreviewUserAgent, req.Header.Get("User-Agent")) deadline, ok := req.Context().Deadline() assert.True(t, ok) assert.LessOrEqual(t, time.Until(deadline), fetchTimeout) return htmlResponse(req, `Title`), nil })) _, err := fetcher.Get(context.Background(), "http://93.184.216.34/page") require.NoError(t, err) require.Equal(t, fetchTimeout, newHTTPClient().Timeout) } func TestHTMLMetaFetcherResolvesAgainstFinalResponseURL(t *testing.T) { fetcher := newTestFetcher(roundTripFunc(func(req *http.Request) (*http.Response, error) { finalRequest := req.Clone(req.Context()) finalRequest.URL, _ = url.Parse("https://cdn.example.com/final/path/") return htmlResponse(finalRequest, ``), nil })) meta, err := fetcher.Get(context.Background(), "http://93.184.216.34/original") require.NoError(t, err) require.Equal(t, "https://cdn.example.com/final/path/cover.png", meta.Image) } func TestHTMLMetaFetcherValidatesResponse(t *testing.T) { tests := []struct { name string status int contentType string }{ {name: "non-2xx", status: http.StatusNotFound, contentType: "text/html"}, {name: "unsupported content type", status: http.StatusOK, contentType: "application/pdf"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { fetcher := newTestFetcher(roundTripFunc(func(req *http.Request) (*http.Response, error) { return response(req, test.status, test.contentType, "body"), nil })) _, err := fetcher.Get(context.Background(), "http://93.184.216.34/page") require.Error(t, err) }) } } func TestHTMLMetaFetcherBoundsHTMLResponse(t *testing.T) { body := `Bounded title` + strings.Repeat("x", maxHTMLMetaBytes) + `` reader := &countingReadCloser{Reader: strings.NewReader(body)} fetcher := newTestFetcher(roundTripFunc(func(req *http.Request) (*http.Response, error) { result := response(req, http.StatusOK, "text/html", "") result.Body = reader return result, nil })) meta, err := fetcher.Get(context.Background(), "http://93.184.216.34/page") require.NoError(t, err) require.Equal(t, "Bounded title", meta.Title) require.LessOrEqual(t, reader.bytesRead, int64(maxHTMLMetaBytes)) } func TestHTMLMetaFetcherContextCancellation(t *testing.T) { requestStarted := make(chan struct{}) fetcher := newTestFetcher(roundTripFunc(func(req *http.Request) (*http.Response, error) { close(requestStarted) <-req.Context().Done() return nil, req.Context().Err() })) ctx, cancel := context.WithCancel(context.Background()) result := make(chan error, 1) go func() { _, err := fetcher.Get(ctx, "http://93.184.216.34/page") result <- err }() <-requestStarted cancel() require.ErrorIs(t, <-result, context.Canceled) } func TestHTMLMetaFetcherCache(t *testing.T) { var now = time.Date(2026, time.August, 17, 0, 0, 0, 0, time.UTC) var successCalls atomic.Int32 successFetcher := newTestFetcher(roundTripFunc(func(req *http.Request) (*http.Response, error) { successCalls.Add(1) return htmlResponse(req, `Cached title`), nil })) successFetcher.now = func() time.Time { return now } first, err := successFetcher.Get(context.Background(), "HTTP://93.184.216.34:80/page#one") require.NoError(t, err) first.Title = "mutated" second, err := successFetcher.Get(context.Background(), "http://93.184.216.34/page#two") require.NoError(t, err) require.Equal(t, "Cached title", second.Title) require.EqualValues(t, 1, successCalls.Load()) now = now.Add(successCacheTTL + time.Second) _, err = successFetcher.Get(context.Background(), "http://93.184.216.34/page") require.NoError(t, err) require.EqualValues(t, 2, successCalls.Load()) var failureCalls atomic.Int32 failureFetcher := newTestFetcher(roundTripFunc(func(req *http.Request) (*http.Response, error) { failureCalls.Add(1) return response(req, http.StatusBadGateway, "text/html", "failure"), nil })) failureFetcher.now = func() time.Time { return now } for range 2 { _, err := failureFetcher.Get(context.Background(), "http://93.184.216.34/failure") require.Error(t, err) } require.EqualValues(t, 1, failureCalls.Load()) now = now.Add(failureCacheTTL + time.Second) _, err = failureFetcher.Get(context.Background(), "http://93.184.216.34/failure") require.Error(t, err) require.EqualValues(t, 2, failureCalls.Load()) } func TestHTMLMetaFetcherBoundsCache(t *testing.T) { fetcher := NewHTMLMetaFetcher() now := time.Date(2026, time.August, 17, 0, 0, 0, 0, time.UTC) fetcher.now = func() time.Time { return now } for index := range maxCacheEntries + 1 { fetcher.setCached(fmt.Sprintf("https://example.com/%d", index), &HTMLMeta{Title: "title"}, nil, successCacheTTL) now = now.Add(time.Second) } require.Len(t, fetcher.cache, maxCacheEntries) _, exists := fetcher.cache["https://example.com/0"] require.False(t, exists) } func TestHTMLMetaFetcherCoalescesRequests(t *testing.T) { var calls atomic.Int32 started := make(chan struct{}) release := make(chan struct{}) fetcher := newTestFetcher(roundTripFunc(func(req *http.Request) (*http.Response, error) { if calls.Add(1) == 1 { close(started) } <-release return htmlResponse(req, `Shared title`), nil })) const requestCount = 20 var waitGroup sync.WaitGroup errorsChannel := make(chan error, requestCount) for range requestCount { waitGroup.Add(1) go func() { defer waitGroup.Done() meta, err := fetcher.Get(context.Background(), "http://93.184.216.34/shared") if err == nil && meta.Title != "Shared title" { err = errors.New("unexpected metadata") } errorsChannel <- err }() } <-started close(release) waitGroup.Wait() close(errorsChannel) for err := range errorsChannel { require.NoError(t, err) } require.EqualValues(t, 1, calls.Load()) } func TestHTMLMetaFetcherLimitsConcurrency(t *testing.T) { var active atomic.Int32 var maximum atomic.Int32 entered := make(chan struct{}, 12) release := make(chan struct{}) fetcher := newTestFetcher(roundTripFunc(func(req *http.Request) (*http.Response, error) { current := active.Add(1) for { previous := maximum.Load() if current <= previous || maximum.CompareAndSwap(previous, current) { break } } entered <- struct{}{} <-release active.Add(-1) return htmlResponse(req, `Title`), nil })) const requestCount = 12 var waitGroup sync.WaitGroup for index := range requestCount { waitGroup.Add(1) go func() { defer waitGroup.Done() _, _ = fetcher.Get(context.Background(), "http://93.184.216.34/page?id="+string(rune('a'+index))) }() } for range maxConcurrentFetches { <-entered } select { case <-entered: t.Fatal("more than eight uncached fetches started concurrently") case <-time.After(100 * time.Millisecond): } close(release) waitGroup.Wait() require.EqualValues(t, maxConcurrentFetches, maximum.Load()) } func TestHTMLMetaFetcherRejectsUnsafeURLsAndRedirects(t *testing.T) { fetcher := NewHTMLMetaFetcher() _, err := fetcher.Get(context.Background(), "http://192.168.0.1/page") require.ErrorIs(t, err, ErrInternalIP) redirect, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/private", nil) require.NoError(t, err) err = newHTTPClient().CheckRedirect(redirect, nil) require.ErrorIs(t, err, ErrInternalIP) } func TestSecureDialContextRejectsResolvedInternalIP(t *testing.T) { originalLookupIPAddr := lookupIPAddr originalDialContext := dialContext t.Cleanup(func() { lookupIPAddr = originalLookupIPAddr dialContext = originalDialContext }) lookupIPAddr = func(context.Context, string) ([]net.IPAddr, error) { return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil } dialContext = func(context.Context, string, string) (net.Conn, error) { t.Fatal("internal IP should be rejected before dialing") return nil, nil } _, err := secureDialContext(context.Background(), "tcp", "rebind.example:80") require.ErrorIs(t, err, ErrInternalIP) } func TestSecureDialContextDialsResolvedIP(t *testing.T) { originalLookupIPAddr := lookupIPAddr originalDialContext := dialContext t.Cleanup(func() { lookupIPAddr = originalLookupIPAddr dialContext = originalDialContext }) lookupIPAddr = func(context.Context, string) ([]net.IPAddr, error) { return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil } var dialedAddress string dialContext = func(_ context.Context, _ string, address string) (net.Conn, error) { dialedAddress = address clientConn, serverConn := net.Pipe() t.Cleanup(func() { clientConn.Close() serverConn.Close() }) return clientConn, nil } conn, err := secureDialContext(context.Background(), "tcp", "rebind.example:80") require.NoError(t, err) require.NotNil(t, conn) require.Equal(t, "93.184.216.34:80", dialedAddress) } func extractTestMetadata(t *testing.T, markup string) *HTMLMeta { t.Helper() document, err := html.Parse(strings.NewReader(markup)) require.NoError(t, err) sources, _ := extractDocumentMetadata(document) pageURL, err := url.Parse("https://example.com/posts/page") require.NoError(t, err) return mergeMetadata(pageURL, metadataSource{}, sources.openGraph, sources.twitter, sources.jsonLD, sources.standard, sources.semantic) } func newTestFetcher(transport http.RoundTripper) *HTMLMetaFetcher { fetcher := NewHTMLMetaFetcher() fetcher.client = &http.Client{Transport: transport} return fetcher } func htmlResponse(request *http.Request, body string) *http.Response { return response(request, http.StatusOK, "text/html; charset=utf-8", body) } func response(request *http.Request, statusCode int, contentType, body string) *http.Response { return &http.Response{ StatusCode: statusCode, Status: http.StatusText(statusCode), Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(body)), Request: request, } } type countingReadCloser struct { *strings.Reader bytesRead int64 } func (reader *countingReadCloser) Read(buffer []byte) (int, error) { count, err := reader.Reader.Read(buffer) reader.bytesRead += int64(count) return count, err } func (*countingReadCloser) Close() error { return nil }