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
165 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|