diff --git a/internal/handlers/first_bytes_internal_test.go b/internal/handlers/first_bytes_internal_test.go new file mode 100644 index 0000000..7d9eda5 --- /dev/null +++ b/internal/handlers/first_bytes_internal_test.go @@ -0,0 +1,173 @@ +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 +} + +// mismatchedSourceURL returns the URL of mismatchedSourcePath at 50x50 as a +// JPEG on route, "/v1/image/" or "/v1/e/", made with h's generator for the +// encrypted one. +func mismatchedSourceURL(t *testing.T, h *Handlers, route string) string { + t.Helper() + + if route == "/v1/image/" { + return route + allowlistedHost + mismatchedSourcePath + "/50x50.jpeg" + } + + 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 route + 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"), + } + + for bodyName, body := range bodies { + for _, route := range []string{"/v1/image/", "/v1/e/"} { + t.Run(route+", "+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 := mismatchedSourceURL(t, h, route) + + 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) + } + }) + } + } +}