check / check (push) Canceled after 0s
A body whose first bytes are not those of the format its Content-Type names answered 500 on both image routes, was logged as pixa's own error, and was fetched again on every request. Both routes now answer 415 for each error the first-bytes check returns, and the image service logs the refused source at warning level. The failure also gets a negative cache entry, as an upstream error status does, so the source URL is not fetched again for 5 minutes, and meanwhile an image that would need that fetch answers 502. README.md says so. The case of TestService_Get_ConcurrentMissesShareAFailure that expected such a source to be fetched again now expects the negative cache, as #280 (comment) allows. Model: opus-5-5
181 lines
5.0 KiB
Go
181 lines
5.0 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|