forgejo-mcp/operation/user/resources_owner_test.go
Christoph Görn 19f1efd000
fix: 🐛 owner resource: surface orgErr on transport failure, explicit -32003 on both-404
ownerResourceHandler had two related bugs in the user→org fallback:
- when GetOrg failed with orgResp==nil (network/DNS/context-cancel),
  the handler returned MapForgejoError(uri, userErr), masking the
  real transport error behind the original user 404;
- if userErr happened to be nil (u==nil, err==nil SDK edge), the
  same call produced MapForgejoError(nil)==nil and the handler
  returned (nil, nil), violating MCP semantics.

Surface orgErr on transport failure. On both-404 (or any other
ambiguous outcome) return an explicit *ResourceError with code
-32003.

Closes forgejo-mcp-91j.
Ref PR #172 review https://codeberg.org/goern/forgejo-mcp/pulls/172#issuecomment-16022165
2026-05-28 16:11:26 +02:00

247 lines
7.4 KiB
Go

package user
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"codeberg.org/goern/forgejo-mcp/v2/operation/resource"
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
forgejo_sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
"github.com/mark3labs/mcp-go/mcp"
)
func makeOwnerResourceRequest(owner string) mcp.ReadResourceRequest {
return mcp.ReadResourceRequest{
Params: mcp.ReadResourceParams{
URI: "forgejo://owner/" + owner,
},
}
}
// routingHandler routes /users/{login} and /orgs/{orgname} to separate handlers.
type routingHandler struct {
userStatus int
userBody interface{}
orgStatus int
orgBody interface{}
}
func (h *routingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.Contains(r.URL.Path, "/users/") {
w.WriteHeader(h.userStatus)
if h.userBody != nil {
json.NewEncoder(w).Encode(h.userBody)
}
return
}
if strings.Contains(r.URL.Path, "/orgs/") {
w.WriteHeader(h.orgStatus)
if h.orgBody != nil {
json.NewEncoder(w).Encode(h.orgBody)
}
return
}
w.WriteHeader(http.StatusNotFound)
}
func setupOwnerMockServer(t *testing.T, h *routingHandler) *httptest.Server {
t.Helper()
srv := httptest.NewServer(h)
client, err := forgejo_sdk.NewClient(srv.URL, forgejo_sdk.SetForgejoVersion("7.0.0"))
if err != nil {
t.Fatalf("creating test client: %v", err)
}
forgejo.SetClientForTesting(client)
return srv
}
func TestOwnerResourceHandler_HappyPath_User(t *testing.T) {
h := &routingHandler{
userStatus: http.StatusOK,
userBody: map[string]interface{}{
"login": "alice",
"full_name": "Alice Smith",
"html_url": "https://codeberg.org/alice",
"description": "open source dev",
"location": "Berlin",
"website": "https://alice.dev",
"created": "2020-01-01T00:00:00Z",
"followers_count": 10,
"following_count": 5,
},
}
srv := setupOwnerMockServer(t, h)
defer srv.Close()
req := makeOwnerResourceRequest("alice")
contents, err := ownerResourceHandler(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(contents) != 1 {
t.Fatalf("expected 1 content block, got %d", len(contents))
}
block := contents[0].(mcp.TextResourceContents)
if block.MIMEType != "application/json" {
t.Errorf("MIME: got %q, want application/json", block.MIMEType)
}
var payload ownerResourcePayload
if err := json.Unmarshal([]byte(block.Text), &payload); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if payload.Kind != "user" {
t.Errorf("expected kind=user, got %q", payload.Kind)
}
if payload.Login != "alice" {
t.Errorf("expected login=alice, got %q", payload.Login)
}
}
func TestOwnerResourceHandler_HappyPath_OrgFallback(t *testing.T) {
h := &routingHandler{
userStatus: http.StatusNotFound,
userBody: map[string]string{"message": "user not found"},
orgStatus: http.StatusOK,
orgBody: map[string]interface{}{
"username": "forgejo-org",
"full_name": "Forgejo Community",
"description": "The Forgejo org",
"location": "Internet",
"website": "https://forgejo.org",
},
}
srv := setupOwnerMockServer(t, h)
defer srv.Close()
req := makeOwnerResourceRequest("forgejo-org")
contents, err := ownerResourceHandler(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
block := contents[0].(mcp.TextResourceContents)
var payload ownerResourcePayload
if err := json.Unmarshal([]byte(block.Text), &payload); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if payload.Kind != "org" {
t.Errorf("expected kind=org, got %q", payload.Kind)
}
}
func TestOwnerResourceHandler_BothNotFound(t *testing.T) {
h := &routingHandler{
userStatus: http.StatusNotFound,
userBody: map[string]string{"message": "not found"},
orgStatus: http.StatusNotFound,
orgBody: map[string]string{"message": "not found"},
}
srv := setupOwnerMockServer(t, h)
defer srv.Close()
req := makeOwnerResourceRequest("nobody")
_, err := ownerResourceHandler(context.Background(), req)
if err == nil {
t.Fatal("expected error when both user and org return 404")
}
}
func TestOwnerResourceHandler_UserForbidden(t *testing.T) {
h := &routingHandler{
userStatus: http.StatusForbidden,
userBody: map[string]string{"message": "Forbidden"},
}
srv := setupOwnerMockServer(t, h)
defer srv.Close()
req := makeOwnerResourceRequest("secretuser")
_, err := ownerResourceHandler(context.Background(), req)
if err == nil {
t.Fatal("expected error for 403")
}
}
func TestOwnerResourceHandler_BothNotFound_Returns32003(t *testing.T) {
h := &routingHandler{
userStatus: http.StatusNotFound,
userBody: map[string]string{"message": "not found"},
orgStatus: http.StatusNotFound,
orgBody: map[string]string{"message": "not found"},
}
srv := setupOwnerMockServer(t, h)
defer srv.Close()
req := makeOwnerResourceRequest("ghost")
_, err := ownerResourceHandler(context.Background(), req)
if err == nil {
t.Fatal("expected error when both user and org return 404")
}
var resErr *resource.ResourceError
if !errors.As(err, &resErr) {
t.Fatalf("expected *resource.ResourceError, got %T: %v", err, err)
}
if resErr.Code != -32003 {
t.Errorf("expected Code -32003, got %d", resErr.Code)
}
if !strings.Contains(resErr.Message, "owner not found") {
t.Errorf("expected message to contain 'owner not found', got %q", resErr.Message)
}
}
func TestOwnerResourceHandler_OrgTransportError_Surfaces_OrgErr(t *testing.T) {
// User returns 404; org endpoint returns a 500 (non-404 error with body present).
h := &routingHandler{
userStatus: http.StatusNotFound,
userBody: map[string]string{"message": "not found"},
orgStatus: http.StatusInternalServerError,
orgBody: map[string]string{"message": "upstream timeout"},
}
srv := setupOwnerMockServer(t, h)
defer srv.Close()
req := makeOwnerResourceRequest("flaky-org")
_, err := ownerResourceHandler(context.Background(), req)
if err == nil {
t.Fatal("expected error when org returns 500")
}
var resErr *resource.ResourceError
if !errors.As(err, &resErr) {
t.Fatalf("expected *resource.ResourceError, got %T: %v", err, err)
}
// Must NOT be the original user 404 message — should contain org error signal.
if strings.Contains(resErr.Message, "not found: ") && !strings.Contains(resErr.Message, "500") {
t.Errorf("error appears to surface user 404 instead of org error: %q", resErr.Message)
}
}
func TestOwnerResourceHandler_UserNilErr_OrgNotFound_Returns32003(t *testing.T) {
// SDK edge: user endpoint returns 404 with empty body (u==nil, userErr may be nil
// depending on SDK version). Org also 404 → must return explicit -32003, not (nil,nil).
h := &routingHandler{
userStatus: http.StatusNotFound,
userBody: nil, // empty body — SDK may return nil error
orgStatus: http.StatusNotFound,
orgBody: map[string]string{"message": "not found"},
}
srv := setupOwnerMockServer(t, h)
defer srv.Close()
req := makeOwnerResourceRequest("phantom")
_, err := ownerResourceHandler(context.Background(), req)
if err == nil {
t.Fatal("expected error, not (nil,nil), when both endpoints 404")
}
var resErr *resource.ResourceError
if !errors.As(err, &resErr) {
t.Fatalf("expected *resource.ResourceError, got %T: %v", err, err)
}
if resErr.Code != -32003 {
t.Errorf("expected Code -32003, got %d", resErr.Code)
}
}