From 79f7c7c98c350a411054f8ac5e7476184cb4d265 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 07:11:07 +0000 Subject: [PATCH] Test that a source not matching its Content-Type answers 415 Both image routes must answer 415 for an upstream body whose first bytes are not those of the format its Content-Type names, log it as a refused source and not at error level, and answer the next request for that source 502 without fetching it again. The test fails until the fix for https://git.eeqj.de/sneak/pixa/issues/253. Model: opus-5-5 --- .../handlers/first_bytes_internal_test.go | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 internal/handlers/first_bytes_internal_test.go diff --git a/internal/handlers/first_bytes_internal_test.go b/internal/handlers/first_bytes_internal_test.go new file mode 100644 index 0000000..fb3ba30 --- /dev/null +++ b/internal/handlers/first_bytes_internal_test.go @@ -0,0 +1,180 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "image/color" + "log/slog" + "net/http" + "slices" + "testing" + "testing/fstest" + "time" + + "github.com/go-chi/chi/v5" + "sneak.berlin/go/pixa/internal/encurl" + "sneak.berlin/go/pixa/internal/httpfetcher" + "sneak.berlin/go/pixa/internal/imgcache" +) + +// mismatchedSourcePath is the path of the source that +// TestFirstBytesNotMatchingContentType serves on allowlistedHost. The mock +// fetcher names a file's Content-Type from its extension, so the source is +// sent as image/png. +const mismatchedSourcePath = "/images/source.png" + +// newRoutesFetchingThrough returns both image routes, and the Handlers behind +// them, whose image service fetches through fetcher and has allowlistedHost on +// its allowlist. The handlers and the image service log to log. +func newRoutesFetchingThrough( + t *testing.T, fetcher httpfetcher.Fetcher, log *slog.Logger, +) (*Handlers, http.Handler) { + t.Helper() + + cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{ + StateDir: t.TempDir(), + NegativeTTL: 5 * time.Minute, + }) + if err != nil { + t.Fatalf("imgcache.NewCache() error = %v", err) + } + + svc, err := imgcache.NewService(&imgcache.ServiceConfig{ + Cache: cache, + Fetcher: fetcher, + SigningKey: testSigningKey, + Allowlist: []string{allowlistedHost}, + Logger: log, + }) + 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: log, imgSvc: svc, encGen: encGen} + + r := chi.NewRouter() + r.Get("/v1/image/*", h.HandleImage()) + r.Get("/v1/e/{token}/*", h.HandleImageEnc()) + + return h, r +} + +// mismatchedSourcePlainURL returns the /v1/image/ URL of mismatchedSourcePath +// on allowlistedHost at 50x50 as a JPEG. +func mismatchedSourcePlainURL(*testing.T, *Handlers) string { + return "/v1/image/" + allowlistedHost + mismatchedSourcePath + "/50x50.jpeg" +} + +// mismatchedSourceEncURL returns an encrypted /v1/e/ URL, made with h's +// generator, of mismatchedSourcePath on allowlistedHost at 50x50 as a JPEG. +func mismatchedSourceEncURL(t *testing.T, h *Handlers) string { + t.Helper() + + token, err := h.encGen.Generate(&encurl.Payload{ + SourceHost: allowlistedHost, + SourcePath: mismatchedSourcePath, + Width: 50, + Height: 50, + Format: imgcache.FormatJPEG, + }) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + + return "/v1/e/" + token + "/img.jpg" +} + +// loggedLevels reads the JSON log lines in logs and returns the messages +// logged at each level. +func loggedLevels(t *testing.T, logs *bytes.Buffer) map[string][]string { + t.Helper() + + t.Logf("logged:\n%s", logs.String()) + + messages := make(map[string][]string) + + dec := json.NewDecoder(logs) + for dec.More() { + var line struct { + Level string `json:"level"` + Msg string `json:"msg"` + } + + err := dec.Decode(&line) + if err != nil { + t.Fatalf("decoding log line: %v", err) + } + + messages[line.Level] = append(messages[line.Level], line.Msg) + } + + return messages +} + +// TestFirstBytesNotMatchingContentType checks that both image routes answer +// 415 for an upstream body whose first bytes are not those of the format its +// Content-Type names, and log it as a refused source, not as an error of +// pixa's. The next request for that source must answer 502 without fetching +// it again, as after an upstream error status. +func TestFirstBytesNotMatchingContentType(t *testing.T) { + t.Parallel() + + bodies := map[string][]byte{ + "JPEG sent as image/png": generateTestJPEG(t, 10, 10, color.Black), + "text sent as image/png": []byte("This is just text, not a PNG"), + "body too short to name a format": []byte("short"), + } + + // The source's URL on each image route. + sourceURLs := map[string]func(*testing.T, *Handlers) string{ + "plain URL": mismatchedSourcePlainURL, + "encrypted URL": mismatchedSourceEncURL, + } + + for bodyName, body := range bodies { + for urlName, sourceURL := range sourceURLs { + t.Run(urlName+", "+bodyName, func(t *testing.T) { + t.Parallel() + + fetcher := &countingFetcher{ + Fetcher: httpfetcher.NewMock(fstest.MapFS{ + allowlistedHost + mismatchedSourcePath: &fstest.MapFile{ + Data: body, + }, + }), + } + + var logs bytes.Buffer + + h, routes := newRoutesFetchingThrough(t, fetcher, + slog.New(slog.NewJSONHandler(&logs, nil))) + target := sourceURL(t, h) + + checkErrorBody(t, sendGet(t, routes, target), + http.StatusUnsupportedMediaType, "unsupported source format") + + levels := loggedLevels(t, &logs) + if len(levels[slog.LevelError.String()]) != 0 { + t.Errorf("logged at error level: %q", + levels[slog.LevelError.String()]) + } + + if !slices.Contains(levels[slog.LevelWarn.String()], "source refused") { + t.Error(`no "source refused" line logged at warning level`) + } + + checkErrorBody(t, sendGet(t, routes, target), + http.StatusBadGateway, "upstream error") + + if n := fetcher.fetches.Load(); n != 1 { + t.Errorf("upstream fetched %d times, want 1", n) + } + }) + } + } +}