package handlers import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "github.com/go-chi/chi/v5" "sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/signature" ) // signedHost is not on the allowlist setupTestHandler builds, so a request // for it needs a valid signature. No image is served for it: a request that // passes the signature check gets 502 from the failed fetch, and one that // fails the check gets 401. const signedHost = "signed.example.com" // getImage sends a GET for target to the image route of fix and returns the // response status. func getImage(t *testing.T, fix *testFixtures, target string) int { t.Helper() r := chi.NewRouter() r.Get("/v1/image/*", fix.handler.HandleImage()) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) t.Logf("GET %s: %d", target, rec.Code) return rec.Code } // TestHandleImage_SignatureCoversQualityAndFit signs a URL for quality 85 // and fit cover, the values the route uses when a URL has no q or fit, and // sends that signature with each q and fit below. func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) { t.Parallel() expires := time.Now().Add(time.Hour) signer := signature.New("test-signing-key-must-be-32-chars") sig := signer.Sign(&signature.Request{ SourceHost: signedHost, SourcePath: "/images/photo.jpg", Width: 50, Height: 50, Format: string(imgcache.FormatJPEG), Quality: 85, FitMode: string(imgcache.FitCover), Expires: expires, }) signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d", signedHost, sig, expires.Unix()) tests := []struct { name string query string wantStatus int }{ {"no q or fit", "", http.StatusBadGateway}, {"q=85 and fit=cover", "&q=85&fit=cover", http.StatusBadGateway}, {"replayed with q=40", "&q=40", http.StatusUnauthorized}, {"replayed with fit=contain", "&fit=contain", http.StatusUnauthorized}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() status := getImage(t, setupTestHandler(t), signedURL+tt.query) if status != tt.wantStatus { t.Errorf("status = %d, want %d", status, tt.wantStatus) } }) } } // TestHandleImage_GeneratedSignedURLVerifies sends URLs built by the // service's signed-URL generator to the route. func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) { t.Parallel() tests := []struct { name string quality int fitMode imgcache.FitMode }{ {"quality 40 and fit contain", 40, imgcache.FitContain}, {"quality and fit unset", 0, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() fix := setupTestHandler(t) signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{ SourceHost: signedHost, SourcePath: "/images/photo.jpg", Size: imgcache.Size{Width: 50, Height: 50}, Format: imgcache.FormatJPEG, Quality: tt.quality, FitMode: tt.fitMode, }, time.Hour) if err != nil { t.Fatalf("GenerateSignedURL() error = %v", err) } status := getImage(t, fix, signedURL) if status != http.StatusBadGateway { t.Errorf("status = %d, want %d", status, http.StatusBadGateway) } }) } } // TestHandleImage_InvalidExp_Returns400 sends a signed-host URL whose exp is // not a whole number, and one whose exp is empty. Each is refused with 400 // naming exp and the value, not with the 401 a URL without exp still gets. func TestHandleImage_InvalidExp_Returns400(t *testing.T) { t.Parallel() tests := []struct { query string wantStatus int wantError string }{ {"sig=x&exp=banana", http.StatusBadRequest, `invalid exp: not a number, got "banana"`}, {"sig=x&exp=", http.StatusBadRequest, `invalid exp: not a number, got ""`}, {"sig=x", http.StatusUnauthorized, "unauthorized"}, } for _, tt := range tests { t.Run(tt.query, func(t *testing.T) { t.Parallel() fix := setupTestHandler(t) r := chi.NewRouter() r.Get("/v1/image/*", fix.handler.HandleImage()) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/image/"+signedHost+"/images/photo.jpg/50x50.jpeg?"+tt.query, nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body) var body struct { Error string `json:"error"` } err := json.NewDecoder(rec.Body).Decode(&body) if err != nil { t.Fatalf("decoding response body: %v", err) } if rec.Code != tt.wantStatus || body.Error != tt.wantError { t.Errorf("got %d %q, want %d %q", rec.Code, body.Error, tt.wantStatus, tt.wantError) } }) } }