forgejo-mcp/pkg/forgejo/rawhttp_test.go
Christoph Görn 34effc817f style: 🎨 apply gofmt -w to operation/* and pkg/*
Mechanical formatting cleanup with no semantic changes. Required so the
new gofmt-check step in .tekton/tasks/go-ci.yaml passes on first run.
2026-05-24 09:24:48 +02:00

287 lines
8.1 KiB
Go

package forgejo
import (
"context"
"encoding/json"
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"codeberg.org/goern/forgejo-mcp/v2/pkg/flag"
)
type capturedReq struct {
method string
path string
auth string
ua string
ctype string
body []byte
}
func newCaptureServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request, c *capturedReq)) (*httptest.Server, *capturedReq) {
t.Helper()
c := &capturedReq{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c.method = r.Method
c.path = r.URL.Path
c.auth = r.Header.Get("Authorization")
c.ua = r.Header.Get("User-Agent")
c.ctype = r.Header.Get("Content-Type")
c.body, _ = io.ReadAll(r.Body)
handler(w, r, c)
}))
t.Cleanup(srv.Close)
flag.URL = srv.URL
flag.Token = "test-token"
flag.UserAgent = "forgejo-mcp-test/0.0.1"
return srv, c
}
func TestDoJSON_GetSuccess_HeadersAndDecode(t *testing.T) {
_, c := newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":42,"name":"hello"}`))
})
var out struct {
ID int `json:"id"`
Name string `json:"name"`
}
if err := DoJSON(context.Background(), http.MethodGet, "/repos/o/r/issues/1/assets", nil, &out); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if out.ID != 42 || out.Name != "hello" {
t.Fatalf("decode mismatch: %+v", out)
}
if c.path != "/api/v1/repos/o/r/issues/1/assets" {
t.Fatalf("path: got %q", c.path)
}
if c.auth != "token test-token" {
t.Fatalf("auth header: got %q", c.auth)
}
if c.ua != "forgejo-mcp-test/0.0.1" {
t.Fatalf("user-agent: got %q", c.ua)
}
}
func TestDoJSON_PostBodySerialised(t *testing.T) {
_, c := newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
})
body := map[string]string{"name": "renamed.txt"}
if err := DoJSON(context.Background(), http.MethodPatch, "/x", body, nil); err != nil {
t.Fatalf("err: %v", err)
}
if c.method != http.MethodPatch {
t.Fatalf("method: %s", c.method)
}
if c.ctype != "application/json" {
t.Fatalf("content-type: %s", c.ctype)
}
var got map[string]string
if err := json.Unmarshal(c.body, &got); err != nil {
t.Fatalf("body not JSON: %v body=%s", err, c.body)
}
if got["name"] != "renamed.txt" {
t.Fatalf("body: %v", got)
}
}
func TestDoJSON_204NoContent(t *testing.T) {
newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
w.WriteHeader(http.StatusNoContent)
})
if err := DoJSON(context.Background(), http.MethodDelete, "/x/1", nil, nil); err != nil {
t.Fatalf("expected no error on 204, got: %v", err)
}
}
func TestDoJSON_4xxErrorTypes(t *testing.T) {
for _, tc := range []struct {
name string
status int
want error
}{
{"401", http.StatusUnauthorized, ErrUnauthorized},
{"403", http.StatusForbidden, ErrUnauthorized},
{"404", http.StatusNotFound, ErrNotFound},
{"500", http.StatusInternalServerError, nil},
} {
t.Run(tc.name, func(t *testing.T) {
newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(`{"message":"nope"}`))
})
err := DoJSON(context.Background(), http.MethodGet, "/x", nil, nil)
if err == nil {
t.Fatalf("expected error for %d", tc.status)
}
var he *HTTPError
if !errors.As(err, &he) {
t.Fatalf("expected *HTTPError, got %T", err)
}
if he.StatusCode != tc.status {
t.Fatalf("status: got %d want %d", he.StatusCode, tc.status)
}
if tc.want != nil && !errors.Is(err, tc.want) {
t.Fatalf("expected wrapped %v, got %v", tc.want, err)
}
if !strings.Contains(he.Body, "nope") {
t.Fatalf("body snippet missing payload: %q", he.Body)
}
})
}
}
func TestDoJSONList_404IsEmpty(t *testing.T) {
newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
w.WriteHeader(http.StatusNotFound)
})
var out []map[string]any
if err := DoJSONList(context.Background(), http.MethodGet, "/x", &out); err != nil {
t.Fatalf("404 on list should be nil err, got: %v", err)
}
if len(out) != 0 {
t.Fatalf("expected empty list, got %d", len(out))
}
}
func TestDoMultipart_RoundTrip(t *testing.T) {
srv, c := newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":7,"name":"f.txt"}`))
})
_ = srv
var out struct {
ID int `json:"id"`
Name string `json:"name"`
}
const payload = "hello world"
err := DoMultipart(context.Background(), http.MethodPost, "/repos/o/r/issues/1/assets",
"attachment", "f.txt", "text/plain", strings.NewReader(payload), &out)
if err != nil {
t.Fatalf("err: %v", err)
}
if out.ID != 7 || out.Name != "f.txt" {
t.Fatalf("decoded: %+v", out)
}
mt, ps, err := mime.ParseMediaType(c.ctype)
if err != nil {
t.Fatalf("parse media type: %v", err)
}
if mt != "multipart/form-data" {
t.Fatalf("media type: %s", mt)
}
mr := multipart.NewReader(strings.NewReader(string(c.body)), ps["boundary"])
part, err := mr.NextPart()
if err != nil {
t.Fatalf("next part: %v", err)
}
if part.FormName() != "attachment" {
t.Fatalf("form field name: %s", part.FormName())
}
if part.FileName() != "f.txt" {
t.Fatalf("filename: %s", part.FileName())
}
if got := part.Header.Get("Content-Type"); got != "text/plain" {
t.Fatalf("part content-type: %s", got)
}
got, _ := io.ReadAll(part)
if string(got) != payload {
t.Fatalf("payload mismatch: %q", got)
}
}
func TestDoMultipart_DefaultMimeOctetStream(t *testing.T) {
_, c := newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
})
if err := DoMultipart(context.Background(), http.MethodPost, "/x", "attachment", "x.bin", "", strings.NewReader("a"), nil); err != nil {
t.Fatalf("err: %v", err)
}
_, ps, _ := mime.ParseMediaType(c.ctype)
mr := multipart.NewReader(strings.NewReader(string(c.body)), ps["boundary"])
part, err := mr.NextPart()
if err != nil {
t.Fatalf("next part: %v", err)
}
if got := part.Header.Get("Content-Type"); got != "application/octet-stream" {
t.Fatalf("expected default octet-stream, got %s", got)
}
}
func TestDoRaw_SuccessUnderCap(t *testing.T) {
srv, c := newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
w.Header().Set("Content-Type", "application/pdf")
_, _ = w.Write([]byte("PDFBYTES"))
})
_ = srv
body, ct, err := DoRaw(context.Background(), "/attachments/uuid-1")
if err != nil {
t.Fatalf("err: %v", err)
}
if string(body) != "PDFBYTES" {
t.Fatalf("body: %q", body)
}
if ct != "application/pdf" {
t.Fatalf("content-type: %s", ct)
}
// DoRaw must NOT prepend /api/v1.
if c.path != "/attachments/uuid-1" {
t.Fatalf("path: %s", c.path)
}
if c.auth != "token test-token" {
t.Fatalf("auth header missing on download: %s", c.auth)
}
}
func TestDoRaw_ExceedsCap(t *testing.T) {
bigPayload := make([]byte, MaxInlineDownloadBytes+1024)
for i := range bigPayload {
bigPayload[i] = 'a'
}
newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(bigPayload)
})
_, _, err := DoRaw(context.Background(), "/attachments/big")
if !errors.Is(err, ErrPayloadTooLarge) {
t.Fatalf("expected ErrPayloadTooLarge, got %v", err)
}
}
func TestDoRaw_AbsoluteURLPassThrough(t *testing.T) {
srv, c := newCaptureServer(t, func(w http.ResponseWriter, r *http.Request, _ *capturedReq) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("hi"))
})
body, _, err := DoRaw(context.Background(), srv.URL+"/attachments/abs-uuid")
if err != nil {
t.Fatalf("err: %v", err)
}
if string(body) != "hi" {
t.Fatalf("body: %s", body)
}
if c.path != "/attachments/abs-uuid" {
t.Fatalf("path: %s", c.path)
}
}
func TestResolveURL_BaseRequired(t *testing.T) {
flag.URL = ""
defer func() { flag.URL = "http://x" }()
if _, err := resolveURL("/x"); err == nil {
t.Fatalf("expected error when flag.URL empty")
}
}