package handlers import ( "encoding/json" "fmt" "image/color" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "testing/fstest" "time" "github.com/go-chi/chi/v5" "sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/signature" ) // allowlistedHost is the only host on the allowlist of the image route // newImageRoute builds. const allowlistedHost = "allowed.example.com" // newImageRoute returns the image route of a Handlers whose service fetches // with fetcher and checks signatures with testSigningKey. func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler { t.Helper() cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{ StateDir: t.TempDir(), CacheTTL: time.Hour, NegativeTTL: 5 * time.Minute, }) if err != nil { t.Fatalf("failed to create cache: %v", err) } svc, err := imgcache.NewService(&imgcache.ServiceConfig{ Cache: cache, Fetcher: fetcher, SigningKey: testSigningKey, Allowlist: []string{allowlistedHost}, }) if err != nil { t.Fatalf("failed to create service: %v", err) } h := &Handlers{imgSvc: svc, log: slog.New(slog.DiscardHandler)} r := chi.NewRouter() r.Get("/v1/image/*", h.HandleImage()) return r } // newPhotoFetcher returns a mock fetcher that serves a JPEG at photoPath on // each of hosts, and answers any other URL with an upstream error. func newPhotoFetcher(t *testing.T, hosts ...string) *httpfetcher.MockFetcher { t.Helper() photo := &fstest.MapFile{ Data: generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255}), } files := fstest.MapFS{} for _, host := range hosts { files[host+photoPath] = photo } return httpfetcher.NewMock(files) } // photoURL returns the image route URL of photoPath on host, as a 50x50 JPEG. func photoURL(host string) string { return "/v1/image/" + host + photoPath + "/50x50.jpeg" } // photoURLWithSig returns photoURL(host) with sig and expires as its sig and // exp. func photoURLWithSig(host, sig string, expires time.Time) string { return fmt.Sprintf("%s?sig=%s&exp=%d", photoURL(host), sig, expires.Unix()) } // photoSignature returns the signature of photoURL(host) at the default // quality and fit, made with key and expiring at expires. func photoSignature(key, host string, expires time.Time) string { return signature.New(key).Sign(&signature.Request{ SourceHost: host, SourcePath: photoPath, Width: 50, Height: 50, Format: string(imgcache.FormatJPEG), Quality: 85, FitMode: string(imgcache.FitCover), Expires: expires, }) } // sendGet sends a GET for target to route and returns the response. func sendGet( t *testing.T, route http.Handler, target string, ) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil) rec := httptest.NewRecorder() route.ServeHTTP(rec, req) t.Logf("GET %s: %d", target, rec.Code) return rec } // checkErrorBody checks that rec has status wantStatus and the JSON error body // the image route sends: wantError, wantStatus and the time in RFC 3339. func checkErrorBody( t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantError string, ) { t.Helper() if rec.Code != wantStatus { t.Errorf("status = %d, want %d", rec.Code, wantStatus) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Errorf("Content-Type = %q, want application/json", ct) } var body struct { Error string `json:"error"` Status int `json:"status"` Timestamp string `json:"timestamp"` } err := json.NewDecoder(rec.Body).Decode(&body) if err != nil { t.Fatalf("decoding response body: %v", err) } if body.Error != wantError || body.Status != wantStatus { t.Errorf("body error and status = %q %d, want %q %d", body.Error, body.Status, wantError, wantStatus) } _, err = time.Parse(time.RFC3339, body.Timestamp) if err != nil { t.Errorf("body timestamp: %v", err) } } // TestHandleImage_ErrorAnswers checks the status and the JSON error body the // image route answers each request below with. The JPEG at photoPath exists on // signedHost and on each host below that differs from it, so a request refused // with 401 would otherwise be served. func TestHandleImage_ErrorAnswers(t *testing.T) { t.Parallel() // A signature for signedHost must not verify for any of these. parentHost := "example.com" siblingHost := "other.example.com" subdomainHost := "img." + signedHost appendedHost := signedHost + ".example.net" photos := newPhotoFetcher(t, signedHost, parentHost, siblingHost, subdomainHost, appendedHost) // The real fetcher refuses localhost before any lookup or connection. realFetcher := httpfetcher.New(httpfetcher.DefaultConfig()) exp := time.Now().Add(time.Hour) expired := time.Now().Add(-time.Hour) sig := photoSignature(testSigningKey, signedHost, exp) otherKeySig := photoSignature("another-signing-key", signedHost, exp) expiredSig := photoSignature(testSigningKey, signedHost, expired) localhostSig := photoSignature(testSigningKey, "localhost", exp) // The error every request refused for its signature gets. const unauthorized = "unauthorized" tests := []struct { name string fetcher httpfetcher.Fetcher target string wantStatus int wantError string }{ {"no sig or exp", photos, photoURL(signedHost), http.StatusUnauthorized, unauthorized}, {"exp but no sig", photos, fmt.Sprintf("%s?exp=%d", photoURL(signedHost), exp.Unix()), http.StatusUnauthorized, unauthorized}, {"sig made with another key", photos, photoURLWithSig(signedHost, otherKeySig, exp), http.StatusUnauthorized, unauthorized}, {"sig without its = padding", photos, photoURLWithSig(signedHost, strings.TrimRight(sig, "="), exp), http.StatusUnauthorized, unauthorized}, {"sig in upper case", photos, photoURLWithSig(signedHost, strings.ToUpper(sig), exp), http.StatusUnauthorized, unauthorized}, {"expired sig", photos, photoURLWithSig(signedHost, expiredSig, expired), http.StatusUnauthorized, unauthorized}, {"sig sent for the parent domain", photos, photoURLWithSig(parentHost, sig, exp), http.StatusUnauthorized, unauthorized}, {"sig sent for a sibling host", photos, photoURLWithSig(siblingHost, sig, exp), http.StatusUnauthorized, unauthorized}, {"sig sent for a subdomain", photos, photoURLWithSig(subdomainHost, sig, exp), http.StatusUnauthorized, unauthorized}, {"sig sent with another domain appended", photos, photoURLWithSig(appendedHost, sig, exp), http.StatusUnauthorized, unauthorized}, {"unparseable path", photos, "/v1/image/" + allowlistedHost + photoPath + "/big.jpeg", http.StatusBadRequest, "invalid image URL: invalid size format"}, {"blocked upstream address", realFetcher, photoURLWithSig("localhost", localhostSig, exp), http.StatusForbidden, "forbidden"}, {"upstream error", photos, "/v1/image/" + allowlistedHost + "/images/missing.jpg/50x50.jpeg", http.StatusBadGateway, "upstream error"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() rec := sendGet(t, newImageRoute(t, tt.fetcher), tt.target) checkErrorBody(t, rec, tt.wantStatus, tt.wantError) }) } } // TestHandleImage_AllowlistOrSignature checks that the image route serves an // image without a signature for a host on the allowlist only, and for another // host only with a valid signature. func TestHandleImage_AllowlistOrSignature(t *testing.T) { t.Parallel() photos := newPhotoFetcher(t, allowlistedHost, signedHost) exp := time.Now().Add(time.Hour) sig := photoSignature(testSigningKey, signedHost, exp) tests := []struct { name string target string wantStatus int }{ {"allowlisted host, no sig", photoURL(allowlistedHost), http.StatusOK}, {"other host, no sig", photoURL(signedHost), http.StatusUnauthorized}, {"other host, valid sig", photoURLWithSig(signedHost, sig, exp), http.StatusOK}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() rec := sendGet(t, newImageRoute(t, photos), tt.target) if rec.Code != tt.wantStatus { t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) } }) } }