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 #253. Model: opus-5-5
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user