fluid-telegram/internal/adapter/visual_test.go
tegwick 86cbb43965 FT-WP-0001-T14: upload post images instead of linking them
The adapter fetches visual_url from an allowlisted host (https, image types,
10 MB cap, optional read token) and uploads it as multipart sendPhoto, so the
forge can close without breaking post images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5457@bnt-lap001
Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012
2026-10-08 20:55:32 +02:00

165 lines
5.1 KiB
Go

package adapter
import (
"bytes"
"context"
"io"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
var pngBytes = []byte("\x89PNG\r\n\x1a\nfake-image-bytes")
// imageServer serves one image over TLS and records the Authorization header.
func imageServer(t *testing.T, contentType string, body []byte) (*httptest.Server, *string) {
t.Helper()
var auth string
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", contentType)
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
return srv, &auth
}
func fetcherFor(srv *httptest.Server, token string) *VisualFetcher {
u, _ := url.Parse(srv.URL)
f := NewVisualFetcher([]string{u.Hostname()}, token)
client := srv.Client()
client.CheckRedirect = f.hc.CheckRedirect
f.hc = client
return f
}
func visualReq(t *testing.T, visualURL string) postRequest {
t.Helper()
req := validReq("p-visual")
req.VisualURL = visualURL
return req
}
func TestPublishUploadsFetchedImage(t *testing.T) {
srv, auth := imageServer(t, "image/png", pngBytes)
snd := &fakeSender{}
a := New(Config{ChatID: 1, AttachVisual: true, Visuals: fetcherFor(srv, "read-token-123")}, snd, NewMemStore(), nil)
w := post(t, a.Handler(), visualReq(t, srv.URL+"/coulomb/x/raw/branch/main/assets/a.png"))
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if len(snd.uploads) != 1 || !bytes.Equal(snd.uploads[0].Data, pngBytes) {
t.Fatalf("expected the fetched bytes uploaded, got %d uploads", len(snd.uploads))
}
if snd.uploads[0].Filename != "a.png" || snd.uploads[0].ContentType != "image/png" {
t.Fatalf("upload = %+v", snd.uploads[0])
}
if *auth != "token read-token-123" {
t.Fatalf("authorization header = %q", *auth)
}
}
func TestPublishRefusesUnusableVisuals(t *testing.T) {
big := append([]byte{}, pngBytes...)
big = append(big, bytes.Repeat([]byte{0}, MaxPhotoBytes)...)
cases := []struct {
name, ct string
body []byte
other bool
}{
{"host not allowlisted", "image/png", pngBytes, true},
{"not an image", "text/html", []byte("<html>"), false},
{"over the photo limit", "image/png", big, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv, _ := imageServer(t, tc.ct, tc.body)
f := fetcherFor(srv, "read-token-123")
if tc.other {
f = NewVisualFetcher([]string{"forgejo.example"}, "read-token-123")
}
snd := &fakeSender{}
a := New(Config{ChatID: 1, AttachVisual: true, Visuals: f}, snd, NewMemStore(), nil)
w := post(t, a.Handler(), visualReq(t, srv.URL+"/a.png"))
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if len(snd.uploads)+len(snd.sent)+len(snd.captions) != 0 {
t.Fatal("something was sent to Telegram")
}
if strings.Contains(w.Body.String(), "read-token-123") {
t.Fatal("the read token appeared in the response")
}
})
}
}
func TestFetchRefusesPlainHTTP(t *testing.T) {
f := NewVisualFetcher([]string{"forgejo.coulomb.social"}, "")
if _, err := f.Fetch(context.Background(), "http://forgejo.coulomb.social/a.png"); err == nil {
t.Fatal("fetched over plain http")
}
}
type captureTransport struct {
req *http.Request
body []byte
}
func (c *captureTransport) RoundTrip(r *http.Request) (*http.Response, error) {
c.req = r
c.body, _ = io.ReadAll(r.Body)
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":9}}`))}, nil
}
func TestBotAPIUploadPhotoIsMultipart(t *testing.T) {
ct := &captureTransport{}
b := NewBotAPISender("123:secret-bot-token")
b.hc = &http.Client{Transport: ct}
id, err := b.UploadPhoto(context.Background(), -1004427430639,
Photo{Data: pngBytes, Filename: "a.png", ContentType: "image/png"}, "caption <b>x</b>")
if err != nil || id != 9 {
t.Fatalf("id=%d err=%v", id, err)
}
if ct.req.URL.Path != "/bot123:secret-bot-token/sendPhoto" {
t.Fatalf("path = %s", ct.req.URL.Path)
}
_, params, err := mime.ParseMediaType(ct.req.Header.Get("Content-Type"))
if err != nil {
t.Fatal(err)
}
mr := multipart.NewReader(bytes.NewReader(ct.body), params["boundary"])
got := map[string][]byte{}
for {
p, err := mr.NextPart()
if err != nil {
break
}
data, _ := io.ReadAll(p)
got[p.FormName()] = data
}
if string(got["chat_id"]) != "-1004427430639" || string(got["parse_mode"]) != "HTML" ||
string(got["caption"]) != "caption <b>x</b>" || !bytes.Equal(got["photo"], pngBytes) {
t.Fatalf("multipart fields = %v", got)
}
}
type failTransport struct{}
func (failTransport) RoundTrip(*http.Request) (*http.Response, error) {
return nil, io.ErrUnexpectedEOF
}
func TestBotAPIUploadErrorHidesToken(t *testing.T) {
b := NewBotAPISender("123:secret-bot-token")
b.hc = &http.Client{Transport: failTransport{}}
_, err := b.UploadPhoto(context.Background(), 1, Photo{Data: pngBytes, Filename: "a.png", ContentType: "image/png"}, "c")
if err == nil || strings.Contains(err.Error(), "secret-bot-token") {
t.Fatalf("err = %v", err)
}
}