From 37023939ad1a97f3a8755dbc7a49eb17378cd1c8 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 27 Sep 2026 00:04:31 +0000 Subject: [PATCH] fix(localai-proxy): wrap bare base64 image inputs, block unreachable Depth exports, and validate download URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect/Depth/FaceVerify/FaceAnalyze forwarded the bare base64 core hands the backend, but the upstream's own REST handlers only accept a URL or a data:...;base64, string, so every real call 400'd. Wrap the payload as a data URI (sniffing its MIME type) before sending it. Depth requests for exports/dst now return Unimplemented: those files are written to the upstream's own local disk and are unreachable from here, so failover should move to a local target instead. Generation replies that hand back a URL are now re-fetched by path only, checked against the upstream's known generated-content prefixes, instead of stripping the configured base as a literal string prefix — the old approach broke (or silently trusted an arbitrary host) the moment the upstream advertised a different base via LOCALAI_BASE_URL, a reverse proxy, or X-Forwarded-Host. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Ettore Di Giacinto --- backend/go/localai-proxy/media.go | 147 +++++++++++++---- backend/go/localai-proxy/media_test.go | 213 ++++++++++++++++++------- 2 files changed, 265 insertions(+), 95 deletions(-) diff --git a/backend/go/localai-proxy/media.go b/backend/go/localai-proxy/media.go index e2094a092..b4bff219d 100644 --- a/backend/go/localai-proxy/media.go +++ b/backend/go/localai-proxy/media.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/http" "net/url" "os" "strconv" @@ -32,6 +33,30 @@ func fileToBase64(path string) (string, error) { return base64.StdEncoding.EncodeToString(data), nil } +// toDataURI wraps a base64 payload as a data URI. Detect/Depth/FaceVerify/ +// FaceAnalyze's REST endpoints decode their image field with +// utils.GetContentURIAsBase64, which only accepts an http(s) URL or a +// `data:;base64,` string — never bare base64. That is exactly +// what core hands the backend for these methods (see +// core/http/endpoints/localai/images.go's decodeImageInput, which already +// stripped any data: prefix off before we ever see it), so forwarding it +// unwrapped 400s on every real call. The MIME type isn't carried alongside +// the payload, so it's sniffed from the decoded bytes. +func toDataURI(b64 string) (string, error) { + if b64 == "" { + return "", nil + } + data, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", status.Errorf(codes.InvalidArgument, "localai-proxy: decode base64 image: %v", err) + } + mime := http.DetectContentType(data) + if mime == "application/octet-stream" { + mime = "image/png" + } + return "data:" + mime + ";base64," + b64, nil +} + // genItem is one schema.Item as the image/video/3D generation endpoints // return it: either inline base64 data, or a URL to download the asset from. type genItem struct { @@ -66,20 +91,42 @@ func (p *LocalAIProxy) writeGenItem(ctx context.Context, path string, items []ge if item.URL == "" { return status.Errorf(codes.Internal, "localai-proxy: upstream %s returned neither b64_json nor url", path) } - return p.getToFile(ctx, p.relativePath(item.URL), dst) + rel, err := generatedContentPath(path, item.URL) + if err != nil { + return err + } + return p.getToFile(ctx, rel, dst) } -// relativePath strips the configured upstream base from a URL the upstream -// handed back (e.g. "/generated-images/x.png"), so the follow-up -// download goes through the normal request path instead of concatenating two -// absolute URLs. -func (p *LocalAIProxy) relativePath(raw string) string { - if cfg := p.cfg.Load(); cfg != nil { - if rel, ok := strings.CutPrefix(raw, cfg.base); ok { - return rel +// generatedContentPrefixes are the static paths a LocalAI instance serves +// generated media under (core/http/app.go's e.Static calls). A generation +// reply's URL is only ever safe to re-fetch through this proxy's own +// authenticated client when it resolves to one of these. +var generatedContentPrefixes = []string{"/generated-images/", "/generated-videos/", "/generated-audio/", "/generated-3d/"} + +// generatedContentPath extracts the path to re-download raw from, ignoring +// whatever host is in it. A literal-prefix strip of the configured upstream +// base (the previous approach) breaks the moment the upstream advertises a +// different host than the one this proxy is configured with — LOCALAI_BASE_URL, +// a reverse proxy, or X-Forwarded-Host can all change it — so only the path is +// trusted, and only when it is one this proxy's upstream is actually known to +// serve generated media under; anything else could point anywhere, and taking +// it on faith would let a compromised or misconfigured upstream make this +// proxy fetch (with its bearer key) whatever URL it likes. +func generatedContentPath(callPath, raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", status.Errorf(codes.Internal, "localai-proxy: upstream %s returned an invalid url %q: %v", callPath, raw, err) + } + for _, prefix := range generatedContentPrefixes { + if strings.HasPrefix(u.Path, prefix) { + if u.RawQuery != "" { + return u.Path + "?" + u.RawQuery, nil + } + return u.Path, nil } } - return raw + return "", status.Errorf(codes.InvalidArgument, "localai-proxy: upstream %s returned an unexpected url %q", callPath, raw) } // --- Images --------------------------------------------------------- @@ -335,13 +382,19 @@ type detectResponseBody struct { Detections []detectionBody `json:"detections"` } -// Detect posts Src, which core already carries as a base64 payload (the same -// convention DetectionEndpoint uses to call this method locally), and maps -// each detection, decoding its PNG mask. +// Detect posts Src, which core already carries as a bare base64 payload (the +// same convention DetectionEndpoint uses to call this method locally) — but +// the upstream's own endpoint only accepts a URL or a data URI, so it is +// re-wrapped as one (see toDataURI) — and maps each detection, decoding its +// PNG mask. func (p *LocalAIProxy) Detect(req *pb.DetectOptions) (pb.DetectResponse, error) { + image, err := toDataURI(req.GetSrc()) + if err != nil { + return pb.DetectResponse{}, err + } body := detectRequestBody{ Model: p.model(""), - Image: req.GetSrc(), + Image: image, Prompt: req.GetPrompt(), Points: req.GetPoints(), Boxes: req.GetBoxes(), @@ -371,17 +424,18 @@ func (p *LocalAIProxy) Detect(req *pb.DetectOptions) (pb.DetectResponse, error) return pb.DetectResponse{Detections: detections}, nil } +// depthRequestBody has no Dst/Exports fields: Depth refuses those requests +// before building the body (see the Depth doc comment), so they never reach +// the upstream. type depthRequestBody struct { - Model string `json:"model"` - Image string `json:"image"` - Dst string `json:"dst,omitempty"` - IncludeDepth bool `json:"include_depth,omitempty"` - IncludeConfidence bool `json:"include_confidence,omitempty"` - IncludePose bool `json:"include_pose,omitempty"` - IncludeSky bool `json:"include_sky,omitempty"` - IncludePoints bool `json:"include_points,omitempty"` - PointsConfThresh float32 `json:"points_conf_thresh,omitempty"` - Exports []string `json:"exports,omitempty"` + Model string `json:"model"` + Image string `json:"image"` + IncludeDepth bool `json:"include_depth,omitempty"` + IncludeConfidence bool `json:"include_confidence,omitempty"` + IncludePose bool `json:"include_pose,omitempty"` + IncludeSky bool `json:"include_sky,omitempty"` + IncludePoints bool `json:"include_points,omitempty"` + PointsConfThresh float32 `json:"points_conf_thresh,omitempty"` } type depthResponseBody struct { @@ -399,20 +453,29 @@ type depthResponseBody struct { IsMetric bool `json:"is_metric"` } -// Depth posts Src (a base64 payload, per the same convention as Detect) and -// maps the full response, decoding the point-cloud color bytes. +// Depth posts Src (a bare base64 payload, per the same convention as Detect, +// re-wrapped as a data URI via toDataURI) and maps the full response, +// decoding the point-cloud color bytes. A request for exports or a dst +// directory is refused: those would be written to the upstream's own local +// disk, and ExportPaths would name files this proxy (and whatever asked it +// for them) can never reach. func (p *LocalAIProxy) Depth(req *pb.DepthRequest) (pb.DepthResponse, error) { + if req.GetDst() != "" || len(req.GetExports()) > 0 { + return pb.DepthResponse{}, unimplemented("Depth exports (written to the upstream's own local disk, unreachable from here)") + } + image, err := toDataURI(req.GetSrc()) + if err != nil { + return pb.DepthResponse{}, err + } body := depthRequestBody{ Model: p.model(""), - Image: req.GetSrc(), - Dst: req.GetDst(), + Image: image, IncludeDepth: req.GetIncludeDepth(), IncludeConfidence: req.GetIncludeConfidence(), IncludePose: req.GetIncludePose(), IncludeSky: req.GetIncludeSky(), IncludePoints: req.GetIncludePoints(), PointsConfThresh: req.GetPointsConfThresh(), - Exports: req.GetExports(), } var resp depthResponseBody if err := p.postJSON(context.Background(), "/v1/depth", body, &resp); err != nil { @@ -472,11 +535,21 @@ type faceVerifyResponseBody struct { Img2AntispoofScore *float32 `json:"img2_antispoof_score,omitempty"` } -// FaceVerify posts Img1/Img2, which core already carries as base64 (the same -// convention FaceVerifyEndpoint uses to call this method locally). +// FaceVerify posts Img1/Img2, which core already carries as bare base64 (the +// same convention FaceVerifyEndpoint uses to call this method locally) — +// re-wrapped as data URIs via toDataURI, since the upstream's own endpoint +// only accepts a URL or a data URI. func (p *LocalAIProxy) FaceVerify(req *pb.FaceVerifyRequest) (pb.FaceVerifyResponse, error) { + img1, err := toDataURI(req.GetImg1()) + if err != nil { + return pb.FaceVerifyResponse{}, err + } + img2, err := toDataURI(req.GetImg2()) + if err != nil { + return pb.FaceVerifyResponse{}, err + } body := faceVerifyRequestBody{ - Model: p.model(""), Img1: req.GetImg1(), Img2: req.GetImg2(), + Model: p.model(""), Img1: img1, Img2: img2, Threshold: req.GetThreshold(), AntiSpoofing: req.GetAntiSpoofing(), } var resp faceVerifyResponseBody @@ -536,10 +609,16 @@ type faceAnalyzeResponseBody struct { Faces []faceAnalysisBody `json:"faces"` } -// FaceAnalyze posts Img, which core already carries as base64. +// FaceAnalyze posts Img, which core already carries as bare base64 — +// re-wrapped as a data URI via toDataURI, since the upstream's own endpoint +// only accepts a URL or a data URI. func (p *LocalAIProxy) FaceAnalyze(req *pb.FaceAnalyzeRequest) (pb.FaceAnalyzeResponse, error) { + img, err := toDataURI(req.GetImg()) + if err != nil { + return pb.FaceAnalyzeResponse{}, err + } body := faceAnalyzeRequestBody{ - Model: p.model(""), Img: req.GetImg(), Actions: req.GetActions(), AntiSpoofing: req.GetAntiSpoofing(), + Model: p.model(""), Img: img, Actions: req.GetActions(), AntiSpoofing: req.GetAntiSpoofing(), } var resp faceAnalyzeResponseBody if err := p.postJSON(context.Background(), "/v1/face/analyze", body, &resp); err != nil { diff --git a/backend/go/localai-proxy/media_test.go b/backend/go/localai-proxy/media_test.go index 2e2a970e9..491845fe3 100644 --- a/backend/go/localai-proxy/media_test.go +++ b/backend/go/localai-proxy/media_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/base64" + "encoding/json" "net/http" "os" "path/filepath" @@ -11,6 +12,7 @@ import ( "google.golang.org/grpc/codes" pb "github.com/mudler/LocalAI/pkg/grpc/proto" + "github.com/mudler/LocalAI/pkg/utils" ) // b64 is the base64 encoding a spec expects the proxy to have produced from @@ -84,6 +86,38 @@ var _ = Describe("media methods", func() { Expect(codeOf(err)).To(Equal(codes.Unavailable)) Expect(dst).NotTo(BeAnExistingFile()) }) + + It("downloads a reply URL from the configured upstream even when its host does not match", func() { + // A reverse proxy, LOCALAI_BASE_URL, or X-Forwarded-Host can all make + // the upstream hand back a URL on a different host than the one this + // proxy is configured with. Only the path should matter: the proxy + // must still fetch it from cfg.base (this fake upstream), with its + // own bearer key, never from the host named in the URL. + p := loadProxy(up, nil) + up.replyJSON("/v1/images/generations", map[string]any{ + "data": []map[string]any{{"url": "http://mismatched-host.invalid:1/generated-images/out.png"}}, + }) + up.script("/generated-images/out.png", scriptedResponse{Status: http.StatusOK, ContentType: "image/png", Body: "downloaded-bytes"}) + dst := filepath.Join(GinkgoT().TempDir(), "out.png") + + Expect(p.GenerateImage(&pb.GenerateImageRequest{PositivePrompt: "a cat", Dst: dst})).To(Succeed()) + + got, err := os.ReadFile(dst) + Expect(err).NotTo(HaveOccurred()) + Expect(string(got)).To(Equal("downloaded-bytes")) + }) + + It("refuses a reply URL whose path is not a known generated-content path", func() { + p := loadProxy(up, nil) + up.replyJSON("/v1/images/generations", map[string]any{ + "data": []map[string]any{{"url": up.URL + "/etc/passwd"}}, + }) + dst := filepath.Join(GinkgoT().TempDir(), "out.png") + + err := p.GenerateImage(&pb.GenerateImageRequest{PositivePrompt: "a cat", Dst: dst}) + Expect(codeOf(err)).To(Equal(codes.InvalidArgument)) + Expect(dst).NotTo(BeAnExistingFile()) + }) }) Describe("UpscaleImage", func() { @@ -211,105 +245,162 @@ var _ = Describe("media methods", func() { }) }) - Describe("Detect", func() { - It("posts the image and maps detections including the mask", func() { - p := loadProxy(up, nil) - mask := base64.StdEncoding.EncodeToString([]byte("png-mask")) - up.replyJSON("/v1/detection", map[string]any{ - "detections": []map[string]any{ - {"x": 1.0, "y": 2.0, "width": 3.0, "height": 4.0, "confidence": 0.9, "class_name": "cat", "mask": mask}, - }, - }) + // pngBytes is a minimal payload with a real PNG magic number, so + // http.DetectContentType (which toDataURI uses) sniffs "image/png" the + // way it would for a real image, and decodeAndCheckImage below can tell + // this test wrote a valid data URI apart from one that merely echoes bare + // base64. + pngBytes := append([]byte("\x89PNG\r\n\x1a\n"), []byte("fake-png-body")...) - res, err := p.Detect(&pb.DetectOptions{Src: "base64-image-data", Prompt: "cat", Threshold: 0.5}) + // decodeAndCheckImage extracts field from an upstream request body and + // decodes it exactly the way the real REST handlers do — with + // utils.GetContentURIAsBase64 (core/http/endpoints/localai/images.go's + // decodeImageInput calls the same function) — so a spec here fails the + // same way a live upstream would if the proxy ever regressed to sending + // bare base64, which that function rejects outright. + decodeAndCheckImage := func(body map[string]any, field string, want []byte) { + raw, _ := body[field].(string) + decoded, err := utils.GetContentURIAsBase64(raw) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "the real upstream would reject %s the same way", field) + got, err := base64.StdEncoding.DecodeString(decoded) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + ExpectWithOffset(1, got).To(Equal(want)) + } + + Describe("Detect", func() { + It("wraps the bare base64 image as a data URI the real upstream decoder accepts, and maps detections", func() { + up = newFakeUpstreamWithHandler(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) + decodeAndCheckImage(body, "image", pngBytes) + Expect(body["prompt"]).To(Equal("cat")) + + mask := base64.StdEncoding.EncodeToString([]byte("png-mask")) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "detections": []map[string]any{ + {"x": 1.0, "y": 2.0, "width": 3.0, "height": 4.0, "confidence": 0.9, "class_name": "cat", "mask": mask}, + }, + }) + }) + DeferCleanup(up.Close) + p := loadProxy(up, nil) + + res, err := p.Detect(&pb.DetectOptions{ + Src: base64.StdEncoding.EncodeToString(pngBytes), Prompt: "cat", Threshold: 0.5, + }) Expect(err).NotTo(HaveOccurred()) Expect(res.Detections).To(HaveLen(1)) Expect(res.Detections[0].ClassName).To(Equal("cat")) Expect(res.Detections[0].Confidence).To(BeNumerically("~", 0.9, 1e-6)) Expect(res.Detections[0].Mask).To(Equal([]byte("png-mask"))) - - req := up.last() - Expect(req.Path).To(Equal("/v1/detection")) - Expect(req.JSON).To(HaveKeyWithValue("image", "base64-image-data")) - Expect(req.JSON).To(HaveKeyWithValue("prompt", "cat")) - Expect(req.JSON).To(HaveKeyWithValue("threshold", BeNumerically("~", 0.5, 1e-6))) }) }) Describe("Depth", func() { - It("posts the image and maps the full depth response", func() { - p := loadProxy(up, nil) - colors := base64.StdEncoding.EncodeToString([]byte("rgb")) - up.replyJSON("/v1/depth", map[string]any{ - "width": 2, "height": 1, "depth": []float64{0.1, 0.2}, - "point_colors": colors, "is_metric": true, - }) + It("wraps the bare base64 image as a data URI and maps the full depth response", func() { + up = newFakeUpstreamWithHandler(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) + decodeAndCheckImage(body, "image", pngBytes) + Expect(body["include_depth"]).To(Equal(true)) - res, err := p.Depth(&pb.DepthRequest{Src: "base64-image-data", IncludeDepth: true}) + colors := base64.StdEncoding.EncodeToString([]byte("rgb")) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "width": 2, "height": 1, "depth": []float64{0.1, 0.2}, + "point_colors": colors, "is_metric": true, + }) + }) + DeferCleanup(up.Close) + p := loadProxy(up, nil) + + res, err := p.Depth(&pb.DepthRequest{Src: base64.StdEncoding.EncodeToString(pngBytes), IncludeDepth: true}) Expect(err).NotTo(HaveOccurred()) Expect(res.Width).To(Equal(int32(2))) Expect(res.Height).To(Equal(int32(1))) Expect(res.Depth).To(Equal([]float32{0.1, 0.2})) Expect(res.PointColors).To(Equal([]byte("rgb"))) Expect(res.IsMetric).To(BeTrue()) + }) - req := up.last() - Expect(req.Path).To(Equal("/v1/depth")) - Expect(req.JSON).To(HaveKeyWithValue("image", "base64-image-data")) - Expect(req.JSON).To(HaveKeyWithValue("include_depth", true)) + It("refuses a request for exports or a dst directory without calling the upstream, so failover moves on", func() { + p := loadProxy(up, nil) + + _, exportsErr := p.Depth(&pb.DepthRequest{Src: "irrelevant", Exports: []string{"glb"}}) + Expect(codeOf(exportsErr)).To(Equal(codes.Unimplemented)) + + _, dstErr := p.Depth(&pb.DepthRequest{Src: "irrelevant", Dst: "/some/output/dir"}) + Expect(codeOf(dstErr)).To(Equal(codes.Unimplemented)) + + Expect(up.recorded()).To(BeEmpty(), "an exports/dst request must never reach the upstream") }) }) Describe("FaceVerify", func() { - It("posts both images and maps the response, including liveness fields", func() { - p := loadProxy(up, nil) - up.replyJSON("/v1/face/verify", map[string]any{ - "verified": true, "distance": 0.1, "threshold": 0.4, "confidence": 92.0, "model": "buffalo_l", - "img1_area": map[string]any{"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0}, - "img2_area": map[string]any{"x": 5.0, "y": 6.0, "w": 7.0, "h": 8.0}, - "img1_is_real": true, "img1_antispoof_score": 0.99, - "img2_is_real": false, "img2_antispoof_score": 0.1, - }) + It("wraps both bare base64 images as data URIs and maps the response, including liveness fields", func() { + img2Bytes := append([]byte("\x89PNG\r\n\x1a\n"), []byte("other-face")...) + up = newFakeUpstreamWithHandler(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) + decodeAndCheckImage(body, "img1", pngBytes) + decodeAndCheckImage(body, "img2", img2Bytes) + Expect(body["anti_spoofing"]).To(Equal(true)) - res, err := p.FaceVerify(&pb.FaceVerifyRequest{Img1: "img1-b64", Img2: "img2-b64", AntiSpoofing: true}) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "verified": true, "distance": 0.1, "threshold": 0.4, "confidence": 92.0, "model": "buffalo_l", + "img1_area": map[string]any{"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0}, + "img2_area": map[string]any{"x": 5.0, "y": 6.0, "w": 7.0, "h": 8.0}, + "img1_is_real": true, "img1_antispoof_score": 0.99, + "img2_is_real": false, "img2_antispoof_score": 0.1, + }) + }) + DeferCleanup(up.Close) + p := loadProxy(up, nil) + + res, err := p.FaceVerify(&pb.FaceVerifyRequest{ + Img1: base64.StdEncoding.EncodeToString(pngBytes), Img2: base64.StdEncoding.EncodeToString(img2Bytes), + AntiSpoofing: true, + }) Expect(err).NotTo(HaveOccurred()) Expect(res.Verified).To(BeTrue()) Expect(res.Model).To(Equal("buffalo_l")) Expect(res.Img1Area.W).To(BeNumerically("==", 3)) Expect(res.Img1IsReal).To(BeTrue()) Expect(res.Img2IsReal).To(BeFalse()) - - req := up.last() - Expect(req.Path).To(Equal("/v1/face/verify")) - Expect(req.JSON).To(HaveKeyWithValue("img1", "img1-b64")) - Expect(req.JSON).To(HaveKeyWithValue("img2", "img2-b64")) - Expect(req.JSON).To(HaveKeyWithValue("anti_spoofing", true)) }) }) Describe("FaceAnalyze", func() { - It("posts the image and maps per-face demographic attributes", func() { - p := loadProxy(up, nil) - up.replyJSON("/v1/face/analyze", map[string]any{ - "faces": []map[string]any{ - { - "region": map[string]any{"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0}, - "face_confidence": 0.95, "age": 30.0, "dominant_gender": "Man", - "gender": map[string]any{"Man": 0.9, "Woman": 0.1}, - }, - }, - }) + It("wraps the bare base64 image as a data URI and maps per-face demographic attributes", func() { + up = newFakeUpstreamWithHandler(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) + decodeAndCheckImage(body, "img", pngBytes) + Expect(body["actions"]).To(ConsistOf("age", "gender")) - res, err := p.FaceAnalyze(&pb.FaceAnalyzeRequest{Img: "img-b64", Actions: []string{"age", "gender"}}) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "faces": []map[string]any{ + { + "region": map[string]any{"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0}, + "face_confidence": 0.95, "age": 30.0, "dominant_gender": "Man", + "gender": map[string]any{"Man": 0.9, "Woman": 0.1}, + }, + }, + }) + }) + DeferCleanup(up.Close) + p := loadProxy(up, nil) + + res, err := p.FaceAnalyze(&pb.FaceAnalyzeRequest{ + Img: base64.StdEncoding.EncodeToString(pngBytes), Actions: []string{"age", "gender"}, + }) Expect(err).NotTo(HaveOccurred()) Expect(res.Faces).To(HaveLen(1)) Expect(res.Faces[0].DominantGender).To(Equal("Man")) Expect(res.Faces[0].Gender).To(HaveKeyWithValue("Man", Equal(float32(0.9)))) - - req := up.last() - Expect(req.Path).To(Equal("/v1/face/analyze")) - Expect(req.JSON).To(HaveKeyWithValue("img", "img-b64")) - Expect(req.JSON).To(HaveKeyWithValue("actions", ConsistOf("age", "gender"))) }) })