check / check (push) Canceled after 0s
The new package internal/providers builds the cache, the fetcher, the processor, the image service, the session manager and the encrypted URL generator from the config. providers.Option gives them to pixad's fx graph, and handlers.New receives them built. The server tests build the same graph, with only the fetcher's dial function replaced. database.New opens the database, so these parts can read it while fx builds them; the cache's stop hook still runs before the database's. Each setting's default is declared in internal/config, and internal/providers works out the default cache_max_bytes. The cache's MaxBytes is the one value for the disk cache limit, and 0 turns the disk cache off. The fetcher and the processor take explicit values. Model: opus-5-5
374 lines
8.5 KiB
Go
374 lines
8.5 KiB
Go
package httpfetcher
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
)
|
|
|
|
// testHost is the hostname used by mock fetch tests.
|
|
const testHost = "example.com"
|
|
|
|
func TestIsAllowedContentType(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := New(&Config{
|
|
Timeout: 30 * time.Second,
|
|
MaxResponseSize: 50 << 20,
|
|
MaxConnectionsPerHost: 20,
|
|
MaxConnections: 64,
|
|
})
|
|
|
|
tests := []struct {
|
|
contentType string
|
|
want bool
|
|
}{
|
|
{contentTypeJPEG, true},
|
|
{contentTypePNG, true},
|
|
{contentTypeWebP, true},
|
|
{"image/jpeg; charset=utf-8", true},
|
|
{"IMAGE/JPEG", true},
|
|
{"text/html", false},
|
|
{contentTypeOctetStream, false},
|
|
{"", false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.contentType, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := f.isAllowedContentType(tc.contentType)
|
|
if got != tc.want {
|
|
t.Errorf("isAllowedContentType(%q) = %v, want %v", tc.contentType, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
url string
|
|
want string
|
|
}{
|
|
{"https://example.com/path", testHost},
|
|
{"http://example.com:8080/path", "example.com:8080"},
|
|
{"https://example.com", testHost},
|
|
{"https://example.com?q=1", testHost},
|
|
{"example.com/path", testHost},
|
|
{"", ""},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.url, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := extractHost(tc.url)
|
|
if got != tc.want {
|
|
t.Errorf("extractHost(%q) = %q, want %q", tc.url, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsLocalhost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
host string
|
|
want bool
|
|
}{
|
|
{"localhost", true},
|
|
{"LOCALHOST", true},
|
|
{localhostIPv4, true},
|
|
{localhostIPv6, true},
|
|
{"[::1]", true},
|
|
{"foo.localhost", true},
|
|
{"foo.local", true},
|
|
{testHost, false},
|
|
{"127.0.0.2", false}, // Handled by isPrivateIP, not isLocalhost string match
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.host, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := isLocalhost(tc.host)
|
|
if got != tc.want {
|
|
t.Errorf("isLocalhost(%q) = %v, want %v", tc.host, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsPrivateIP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
ip string
|
|
want bool
|
|
}{
|
|
{localhostIPv4, true}, // loopback
|
|
{"10.0.0.1", true}, // private
|
|
{"192.168.1.1", true}, // private
|
|
{"172.16.0.1", true}, // private
|
|
{"169.254.1.1", true}, // link-local
|
|
{"0.0.0.0", true}, // unspecified
|
|
{"224.0.0.1", true}, // multicast
|
|
{localhostIPv6, true}, // IPv6 loopback
|
|
{"fe80::1", true}, // IPv6 link-local
|
|
{"8.8.8.8", false}, // public
|
|
{"2001:4860:4860::8888", false}, // public IPv6
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.ip, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ip := net.ParseIP(tc.ip)
|
|
if ip == nil {
|
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
|
}
|
|
|
|
got := isPrivateIP(ip)
|
|
if got != tc.want {
|
|
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
if !isPrivateIP(nil) {
|
|
t.Error("isPrivateIP(nil) should return true")
|
|
}
|
|
}
|
|
|
|
// TestIsPrivateIPJudgesEmbeddedIPv4Address checks IPv6 addresses that hold an
|
|
// IPv4 address in their last four bytes: the IPv4-compatible form (::/96) and
|
|
// the NAT64 form (64:ff9b::/96). One holding a loopback, private or
|
|
// link-local IPv4 address is refused as that address is, and one holding a
|
|
// public IPv4 address in the IPv4-compatible form passes.
|
|
func TestIsPrivateIPJudgesEmbeddedIPv4Address(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
ip string
|
|
want bool
|
|
}{
|
|
{"::127.0.0.1", true}, // loopback
|
|
{"::10.0.0.1", true}, // private
|
|
{"::169.254.169.254", true}, // link-local
|
|
{"64:ff9b::7f00:1", true}, // NAT64 form of 127.0.0.1
|
|
{"::8.8.8.8", false}, // public
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.ip, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ip := net.ParseIP(tc.ip)
|
|
if ip == nil {
|
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
|
}
|
|
|
|
got := isPrivateIP(ip)
|
|
t.Logf("isPrivateIP(%s) = %v", ip, got)
|
|
|
|
if got != tc.want {
|
|
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := validateURL(t.Context(), "http://example.com/path", false)
|
|
if !errors.Is(err, ErrUnsupportedScheme) {
|
|
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Use a host that won't resolve (explicit .invalid TLD) so we don't hit DNS.
|
|
err := validateURL(t.Context(), "http://nonexistent.invalid/path", true)
|
|
// We expect a host resolution error, not ErrUnsupportedScheme.
|
|
if errors.Is(err, ErrUnsupportedScheme) {
|
|
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
|
|
}
|
|
}
|
|
|
|
func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := validateURL(t.Context(), "https://localhost/path", false)
|
|
if !errors.Is(err, ErrSSRFBlocked) {
|
|
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateURL_EmptyHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := validateURL(t.Context(), "https:///path", false)
|
|
if !errors.Is(err, ErrInvalidHost) {
|
|
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
|
|
}
|
|
}
|
|
|
|
func TestMockFetcher_FetchesFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mockFS := fstest.MapFS{
|
|
"example.com/images/photo.jpg": &fstest.MapFile{Data: []byte("fake-jpeg-data")},
|
|
}
|
|
|
|
m := NewMock(mockFS)
|
|
|
|
result, err := m.Fetch(context.Background(), "https://example.com/images/photo.jpg")
|
|
if err != nil {
|
|
t.Fatalf("Fetch() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = result.Content.Close() }()
|
|
|
|
if result.ContentType != contentTypeJPEG {
|
|
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
|
|
}
|
|
|
|
data, err := io.ReadAll(result.Content)
|
|
if err != nil {
|
|
t.Fatalf("read content: %v", err)
|
|
}
|
|
|
|
if string(data) != "fake-jpeg-data" {
|
|
t.Errorf("Content = %q, want %q", string(data), "fake-jpeg-data")
|
|
}
|
|
|
|
if result.ContentLength != int64(len("fake-jpeg-data")) {
|
|
t.Errorf("ContentLength = %d, want %d", result.ContentLength, len("fake-jpeg-data"))
|
|
}
|
|
}
|
|
|
|
func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mockFS := fstest.MapFS{}
|
|
m := NewMock(mockFS)
|
|
|
|
_, err := m.Fetch(context.Background(), "https://example.com/missing.jpg")
|
|
if !errors.Is(err, ErrUpstreamError) {
|
|
t.Errorf("Fetch() error = %v, want ErrUpstreamError", err)
|
|
}
|
|
}
|
|
|
|
func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mockFS := fstest.MapFS{
|
|
"example.com/photo.jpg": &fstest.MapFile{Data: []byte("data")},
|
|
}
|
|
m := NewMock(mockFS)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, err := m.Fetch(ctx, "https://example.com/photo.jpg")
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("Fetch() error = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestDetectContentTypeFromPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
path string
|
|
want string
|
|
}{
|
|
{"foo/bar.jpg", contentTypeJPEG},
|
|
{"foo/bar.JPG", contentTypeJPEG},
|
|
{"foo/bar.jpeg", contentTypeJPEG},
|
|
{"foo/bar.png", contentTypePNG},
|
|
{"foo/bar.gif", contentTypeGIF},
|
|
{"foo/bar.webp", contentTypeWebP},
|
|
{"foo/bar.avif", contentTypeAVIF},
|
|
{"foo/bar.svg", contentTypeSVG},
|
|
{"foo/bar.bin", contentTypeOctetStream},
|
|
{"foo/bar", contentTypeOctetStream},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.path, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := detectContentTypeFromPath(tc.path)
|
|
if got != tc.want {
|
|
t.Errorf("detectContentTypeFromPath(%q) = %q, want %q", tc.path, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLimitedReader_EnforcesLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
src := make([]byte, 100)
|
|
r := &limitedReader{
|
|
reader: &byteReader{data: src},
|
|
remaining: 50,
|
|
}
|
|
|
|
buf := make([]byte, 100)
|
|
|
|
n, err := r.Read(buf)
|
|
if err != nil {
|
|
t.Fatalf("first Read error = %v", err)
|
|
}
|
|
|
|
if n > 50 {
|
|
t.Errorf("read %d bytes, should be capped at 50", n)
|
|
}
|
|
|
|
// Drain until limit is exhausted.
|
|
total := n
|
|
for total < 50 {
|
|
nn, err := r.Read(buf)
|
|
if err != nil {
|
|
t.Fatalf("during drain: %v", err)
|
|
}
|
|
|
|
total += nn
|
|
}
|
|
|
|
// Now the limit is exhausted — next read should error.
|
|
_, err = r.Read(buf)
|
|
if !errors.Is(err, ErrResponseTooLarge) {
|
|
t.Errorf("exhausted Read error = %v, want ErrResponseTooLarge", err)
|
|
}
|
|
}
|
|
|
|
// byteReader is a minimal io.Reader over a byte slice for testing.
|
|
type byteReader struct {
|
|
data []byte
|
|
pos int
|
|
}
|
|
|
|
func (r *byteReader) Read(p []byte) (int, error) {
|
|
if r.pos >= len(r.data) {
|
|
return 0, io.EOF
|
|
}
|
|
|
|
n := copy(p, r.data[r.pos:])
|
|
r.pos += n
|
|
|
|
return n, nil
|
|
}
|