Files
pixa/internal/handlers/referer_blocklist_internal_test.go
T
sneak 6d9631a8fc
check / check (push) Waiting to run
Build the image pipeline in pixad's fx graph (closes #313)
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. pixad's fx graph provides them with
providers.Constructors(), 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
2026-10-26 11:16:09 +00:00

193 lines
5.4 KiB
Go

package handlers
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imgcache"
)
// blockedReferer is a page on leech.example, which newRefererRoutes puts on
// referer_blocklist.
const blockedReferer = "https://leech.example/page.html"
// countingFetcher passes each fetch on to the fetcher it holds and counts it.
type countingFetcher struct {
httpfetcher.Fetcher
fetches atomic.Int32
}
// Fetch counts the fetch and passes it on.
func (f *countingFetcher) Fetch(
ctx context.Context, url string,
) (*httpfetcher.FetchResult, error) {
f.fetches.Add(1)
return f.Fetcher.Fetch(ctx, url)
}
// newRefererRoutes returns both image routes of a Handlers whose
// referer_blocklist is "leech.example" and ".hotlinker.example", the
// Handlers, and the fetcher the routes fetch through. The JPEG at photoPath
// exists on allowlistedHost and on signedHost.
func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher) {
t.Helper()
fetcher := &countingFetcher{
Fetcher: newPhotoFetcher(t, allowlistedHost, signedHost),
}
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
StateDir: t.TempDir(),
NegativeTTL: 5 * time.Minute,
MaxBytes: 1 << 30,
}, prometheus.NewRegistry())
if err != nil {
t.Fatalf("imgcache.NewCache() error = %v", err)
}
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache,
Fetcher: fetcher,
Processor: imageprocessor.New(imageprocessor.Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 2,
}),
SigningKey: testSigningKey,
Allowlist: []string{allowlistedHost},
MaxResponseSize: 50 << 20,
})
if err != nil {
t.Fatalf("imgcache.NewService() error = %v", err)
}
encGen, err := encurl.NewGenerator(testSigningKey)
if err != nil {
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
h := &Handlers{
log: slog.New(slog.DiscardHandler),
imgSvc: svc,
encGen: encGen,
refererBlocklist: allowlist.New(
[]string{"leech.example", ".hotlinker.example"}),
}
r := chi.NewRouter()
r.Get("/v1/image/*", h.HandleImage())
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
return r, h, fetcher
}
// getWithReferer sends a GET for target to routes with referer as its
// Referer header, or with none when referer is empty, and returns the
// response.
func getWithReferer(
t *testing.T, routes http.Handler, target, referer string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
if referer != "" {
req.Header.Set("Referer", referer)
}
rec := httptest.NewRecorder()
routes.ServeHTTP(rec, req)
t.Logf("GET %s with Referer %q: %d", target, referer, rec.Code)
return rec
}
// TestRefererBlocklist verifies that both image routes refuse a request whose
// Referer names a host on referer_blocklist with 403 and the JSON error,
// without fetching from the upstream host, and serve a request with no
// Referer, one that does not parse, or one naming any other host. Hosts are
// matched as allowlist_hosts matches them.
func TestRefererBlocklist(t *testing.T) {
t.Parallel()
cases := []struct {
name string
referer string
want int
}{
{"no referer", "", http.StatusOK},
{"unlisted host", "https://unlisted.example/page.html", http.StatusOK},
{"unparseable", "%zz", http.StatusOK},
{"listed host", blockedReferer, http.StatusForbidden},
{"subdomain of listed host", "https://www.leech.example/", http.StatusOK},
{"subdomain of dot pattern", "https://www.hotlinker.example/a.html",
http.StatusForbidden},
{"dot pattern without its dot", "https://hotlinker.example/",
http.StatusForbidden},
{"host continuing past dot pattern",
"https://hotlinker.example.evil.example/", http.StatusOK},
}
// The photo's URL on each image route.
photoURLs := map[string]func(t *testing.T, h *Handlers) string{
"plain URL": func(t *testing.T, _ *Handlers) string {
t.Helper()
return photoURL(allowlistedHost)
},
"encrypted URL": encPhotoURL,
}
for urlName, photoURLFor := range photoURLs {
for _, tc := range cases {
t.Run(urlName+", "+tc.name, func(t *testing.T) {
t.Parallel()
routes, h, fetcher := newRefererRoutes(t)
rec := getWithReferer(t, routes, photoURLFor(t, h), tc.referer)
if tc.want == http.StatusOK {
requireServedPhoto(t, rec)
return
}
checkErrorBody(t, rec, http.StatusForbidden, "referer blocked")
if n := fetcher.fetches.Load(); n != 0 {
t.Errorf("upstream fetched %d times, want 0", n)
}
})
}
}
}
// TestBlockedRefererRefusedWhenImageIsCached verifies that a request whose
// Referer is on referer_blocklist is refused even when the image it asks for
// is already cached, so the answer does not depend on the cache.
func TestBlockedRefererRefusedWhenImageIsCached(t *testing.T) {
t.Parallel()
routes, h, _ := newRefererRoutes(t)
for _, target := range []string{photoURL(allowlistedHost), encPhotoURL(t, h)} {
requireServedPhoto(t, getWithReferer(t, routes, target, ""))
rec := getWithReferer(t, routes, target, blockedReferer)
checkErrorBody(t, rec, http.StatusForbidden, "referer blocked")
}
}