fix(llamaswap): a headerless non-image response was returned as a PNG
Gadfly on #23, blocking, 2/2 agreement — and it is the exact defect this whole line of work has been about: a call that succeeds while handing back the wrong bytes. sniffImageMIME falls back to image/png when detection is inconclusive, and the guard only consulted Content-Type. A response with NO Content-Type therefore skipped the check entirely and was labelled a PNG. The shim answers JSON on a semantic miss (no face found in the source or target), which is precisely the body that would have sailed through as a successful image. The check now validates the BYTES — http.DetectContentType must say image/ — and the reported MIME prefers the server's own label only when that label is itself an image type. Break-checked by restoring the header-only condition, which fails the new test. Also from that review: - index is documented as ignored under all=true, so a negative one is no longer rejected there; it is still rejected when it would actually be sent, and both halves are tested. - initImageFilename (video.go) was imageFilename with the base fixed to "frame" and now delegates to it — two copies of one extension table is how they drift. - DetectedFace carried Width/Height alongside Box, two sources of truth for one fact that can disagree after any transform. Now a Size() method derived from Box. - a dead `apiErr` in the test (declared, then `_ = apiErr`) was an abandoned errors.As check; it is wired up and now asserts callers can classify the error. - swapImg duplicated editInit verbatim; removed. Not taken: adding a FaceSwapProvider/ModelOption surface to match the other optional imagegen capabilities (single-model finding). There are no options to carry yet, and inventing an empty option type to look symmetrical would be API surface with nothing behind it. Worth revisiting when a real knob exists.
This commit is contained in:
@@ -3,6 +3,7 @@ package llamaswap
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
@@ -15,15 +16,6 @@ import (
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
)
|
||||
|
||||
func swapImg(t *testing.T) imagegen.Image {
|
||||
t.Helper()
|
||||
raw, err := base64.StdEncoding.DecodeString(onePixelPNG)
|
||||
if err != nil {
|
||||
t.Fatalf("decode fixture: %v", err)
|
||||
}
|
||||
return imagegen.Image{MIME: "image/png", Data: raw}
|
||||
}
|
||||
|
||||
// parseParts pulls the multipart form a handler received.
|
||||
func parseParts(t *testing.T, r *http.Request) (files map[string][]byte, fields map[string]string) {
|
||||
t.Helper()
|
||||
@@ -73,7 +65,7 @@ func TestFaceSwapSendsBothFiles(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("model: %v", err)
|
||||
}
|
||||
img := swapImg(t)
|
||||
img := editInit(t)
|
||||
res, err := m.FaceSwap(context.Background(), imagegen.FaceSwapRequest{Target: img, Source: img},
|
||||
imagegen.WithFaceIndex(2))
|
||||
if err != nil {
|
||||
@@ -111,7 +103,7 @@ func TestFaceSwapAllSuppressesIndex(t *testing.T) {
|
||||
|
||||
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
||||
m, _ := p.FaceSwapModel("faceswap")
|
||||
img := swapImg(t)
|
||||
img := editInit(t)
|
||||
if _, err := m.FaceSwap(context.Background(),
|
||||
imagegen.FaceSwapRequest{Target: img, Source: img, Index: new(int), All: true}); err != nil {
|
||||
t.Fatalf("faceswap: %v", err)
|
||||
@@ -129,7 +121,7 @@ func TestFaceSwapAllSuppressesIndex(t *testing.T) {
|
||||
func TestFaceSwapRejectsMissingImages(t *testing.T) {
|
||||
p := New(WithBaseURL("http://example.invalid"))
|
||||
m, _ := p.FaceSwapModel("faceswap")
|
||||
img := swapImg(t)
|
||||
img := editInit(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
req imagegen.FaceSwapRequest
|
||||
@@ -157,16 +149,18 @@ func TestFaceSwapRejectsNonImageResponse(t *testing.T) {
|
||||
|
||||
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
||||
m, _ := p.FaceSwapModel("faceswap")
|
||||
img := swapImg(t)
|
||||
img := editInit(t)
|
||||
_, err := m.FaceSwap(context.Background(), imagegen.FaceSwapRequest{Target: img, Source: img})
|
||||
if err == nil {
|
||||
t.Fatal("a JSON body was accepted as an image")
|
||||
}
|
||||
var apiErr *llm.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("err = %T, want *llm.APIError so callers can classify it", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no_face_in_source") {
|
||||
t.Errorf("err = %v, want it to relay the shim's reason", err)
|
||||
}
|
||||
_ = apiErr
|
||||
}
|
||||
|
||||
// TestListFacesParsesOrdering: the shim's left-to-right index is the contract
|
||||
@@ -185,7 +179,7 @@ func TestListFacesParsesOrdering(t *testing.T) {
|
||||
|
||||
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
||||
m, _ := p.FaceSwapModel("faceswap")
|
||||
faces, err := m.ListFaces(context.Background(), swapImg(t))
|
||||
faces, err := m.ListFaces(context.Background(), editInit(t))
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
@@ -208,7 +202,60 @@ func TestListFacesRejectsShortBox(t *testing.T) {
|
||||
|
||||
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
||||
m, _ := p.FaceSwapModel("faceswap")
|
||||
if _, err := m.ListFaces(context.Background(), swapImg(t)); err == nil {
|
||||
if _, err := m.ListFaces(context.Background(), editInit(t)); err == nil {
|
||||
t.Fatal("a 2-element box was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFaceSwapRejectsHeaderlessNonImage is the regression for gadfly's
|
||||
// blocking finding on #23, agreed by both models. sniffImageMIME falls back
|
||||
// to image/png when detection is inconclusive, and the original guard only
|
||||
// looked at Content-Type — so a JSON error body sent WITHOUT a Content-Type
|
||||
// header was labelled a PNG and returned as a successful image. The shim
|
||||
// answers JSON on a semantic miss, which is precisely the body that would
|
||||
// have sailed through.
|
||||
func TestFaceSwapRejectsHeaderlessNonImage(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Explicitly no Content-Type — Go only sets one if we write before
|
||||
// deleting it, so clear it to model a bare upstream response.
|
||||
w.Header()["Content-Type"] = nil
|
||||
_, _ = w.Write([]byte(`{"detail":{"error":"no_face_in_target"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
||||
m, _ := p.FaceSwapModel("faceswap")
|
||||
img := editInit(t)
|
||||
_, err := m.FaceSwap(context.Background(), imagegen.FaceSwapRequest{Target: img, Source: img})
|
||||
if err == nil {
|
||||
t.Fatal("a headerless JSON body was accepted and would have been returned as image/png")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no_face_in_target") {
|
||||
t.Errorf("err = %v, want it to relay what actually came back", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFaceSwapAllowsNegativeIndexUnderAll: index is documented as ignored
|
||||
// when all=true, so validating it there would reject a well-formed request.
|
||||
func TestFaceSwapAllowsNegativeIndexUnderAll(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
raw, _ := base64.StdEncoding.DecodeString(onePixelPNG)
|
||||
_, _ = w.Write(raw)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
||||
m, _ := p.FaceSwapModel("faceswap")
|
||||
img := editInit(t)
|
||||
neg := -1
|
||||
if _, err := m.FaceSwap(context.Background(),
|
||||
imagegen.FaceSwapRequest{Target: img, Source: img, Index: &neg, All: true}); err != nil {
|
||||
t.Fatalf("negative index rejected under all=true, where it is ignored: %v", err)
|
||||
}
|
||||
// ...but still rejected when it WOULD be sent.
|
||||
if _, err := m.FaceSwap(context.Background(),
|
||||
imagegen.FaceSwapRequest{Target: img, Source: img, Index: &neg}); err == nil {
|
||||
t.Error("negative index accepted when it would actually be sent")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user