9275c5a0e1
Bump `golangci-lint` to v2.12.2 and pin `govulncheck` to v1.3.0. Align `.golangci.yml` with the gitea repo's config: enable `revive` `var-naming` (with `skip-package-name-checks`) and drop the test-file exclusion for `errcheck`/`staticcheck`/`unparam`. Fix the now-surfaced `errcheck` violations in test handlers by discarding return values to match the existing codebase pattern. --- This PR was written with the help of Claude Opus 4.7 Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/190 Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-committed-by: silverwind <me@silverwind.io>
382 lines
10 KiB
Go
382 lines
10 KiB
Go
package packages
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
|
|
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
|
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
)
|
|
|
|
func TestPackageReadList(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var gotQuery map[string]string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
gotQuery = map[string]string{}
|
|
for k, v := range r.URL.Query() {
|
|
gotQuery[k] = v[0]
|
|
}
|
|
mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0","html_url":"http://example.com","created_at":"2025-01-01T00:00:00Z","owner":{"login":"test-org"},"creator":{"login":"admin"}}]`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
origHost := flag.Host
|
|
flag.Host = srv.URL
|
|
defer func() { flag.Host = origHost }()
|
|
|
|
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
|
|
|
|
t.Run("basic list", func(t *testing.T) {
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]any{
|
|
"method": "list",
|
|
"owner": "test-org",
|
|
}
|
|
|
|
result, err := packageReadFn(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("packageReadFn() error: %v", err)
|
|
}
|
|
if result.IsError {
|
|
t.Fatal("packageReadFn() returned error result")
|
|
}
|
|
|
|
text := result.Content[0].(mcp.TextContent).Text
|
|
var packages []map[string]any
|
|
if err := json.Unmarshal([]byte(text), &packages); err != nil {
|
|
t.Fatalf("failed to unmarshal result: %v", err)
|
|
}
|
|
if len(packages) != 1 {
|
|
t.Fatalf("expected 1 package, got %d", len(packages))
|
|
}
|
|
if packages[0]["name"] != "myrepo/myimage" {
|
|
t.Errorf("expected name 'myrepo/myimage', got %v", packages[0]["name"])
|
|
}
|
|
if packages[0]["owner"] != "test-org" {
|
|
t.Errorf("expected owner 'test-org', got %v", packages[0]["owner"])
|
|
}
|
|
})
|
|
|
|
t.Run("with type and query filters", func(t *testing.T) {
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]any{
|
|
"method": "list",
|
|
"owner": "test-org",
|
|
"type": "container",
|
|
"q": "myimage",
|
|
}
|
|
|
|
_, err := packageReadFn(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("packageReadFn() error: %v", err)
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if gotQuery["type"] != "container" {
|
|
t.Errorf("expected type=container, got %q", gotQuery["type"])
|
|
}
|
|
if gotQuery["q"] != "myimage" {
|
|
t.Errorf("expected q=myimage, got %q", gotQuery["q"])
|
|
}
|
|
})
|
|
|
|
t.Run("with pagination", func(t *testing.T) {
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]any{
|
|
"method": "list",
|
|
"owner": "test-org",
|
|
"page": float64(2),
|
|
"perPage": float64(10),
|
|
}
|
|
|
|
_, err := packageReadFn(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("packageReadFn() error: %v", err)
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if gotQuery["page"] != "2" {
|
|
t.Errorf("expected page=2, got %q", gotQuery["page"])
|
|
}
|
|
if gotQuery["limit"] != "10" {
|
|
t.Errorf("expected limit=10, got %q", gotQuery["limit"])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPackageReadListVersions(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var gotPath string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
gotPath = r.URL.RawPath
|
|
if gotPath == "" {
|
|
gotPath = r.URL.Path
|
|
}
|
|
mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0"},{"id":2,"type":"container","name":"myrepo/myimage","version":"v2.0.0"}]`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
origHost := flag.Host
|
|
flag.Host = srv.URL
|
|
defer func() { flag.Host = origHost }()
|
|
|
|
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
|
|
|
|
tests := []struct {
|
|
testName string
|
|
name string
|
|
}{
|
|
{"raw slash", "myrepo/myimage"},
|
|
{"pre-encoded slash", "myrepo%2Fmyimage"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.testName, func(t *testing.T) {
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]any{
|
|
"method": "list_versions",
|
|
"owner": "test-org",
|
|
"type": "container",
|
|
"name": tt.name,
|
|
}
|
|
|
|
result, err := packageReadFn(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("packageReadFn() error: %v", err)
|
|
}
|
|
if result.IsError {
|
|
t.Fatal("packageReadFn() returned error result")
|
|
}
|
|
|
|
mu.Lock()
|
|
wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage"
|
|
if gotPath != wantPath {
|
|
t.Errorf("request path = %q, want %q", gotPath, wantPath)
|
|
}
|
|
mu.Unlock()
|
|
|
|
text := result.Content[0].(mcp.TextContent).Text
|
|
var versions []map[string]any
|
|
if err := json.Unmarshal([]byte(text), &versions); err != nil {
|
|
t.Fatalf("failed to unmarshal result: %v", err)
|
|
}
|
|
if len(versions) != 2 {
|
|
t.Fatalf("expected 2 versions, got %d", len(versions))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPackageReadGet(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var gotPath string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
gotPath = r.URL.RawPath
|
|
if gotPath == "" {
|
|
gotPath = r.URL.Path
|
|
}
|
|
mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0","html_url":"http://example.com","created_at":"2025-01-01T00:00:00Z","owner":{"login":"test-org"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
origHost := flag.Host
|
|
flag.Host = srv.URL
|
|
defer func() { flag.Host = origHost }()
|
|
|
|
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
|
|
|
|
tests := []struct {
|
|
testName string
|
|
name string
|
|
}{
|
|
{"raw slash", "myrepo/myimage"},
|
|
{"pre-encoded slash", "myrepo%2Fmyimage"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.testName, func(t *testing.T) {
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]any{
|
|
"method": "get",
|
|
"owner": "test-org",
|
|
"type": "container",
|
|
"name": tt.name,
|
|
"version": "v1.0.0",
|
|
}
|
|
|
|
result, err := packageReadFn(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("packageReadFn() error: %v", err)
|
|
}
|
|
if result.IsError {
|
|
t.Fatal("packageReadFn() returned error result")
|
|
}
|
|
|
|
mu.Lock()
|
|
wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage/v1.0.0"
|
|
if gotPath != wantPath {
|
|
t.Errorf("request path = %q, want %q", gotPath, wantPath)
|
|
}
|
|
mu.Unlock()
|
|
|
|
text := result.Content[0].(mcp.TextContent).Text
|
|
var pkg map[string]any
|
|
if err := json.Unmarshal([]byte(text), &pkg); err != nil {
|
|
t.Fatalf("failed to unmarshal result: %v", err)
|
|
}
|
|
if pkg["name"] != "myrepo/myimage" {
|
|
t.Errorf("expected name 'myrepo/myimage', got %v", pkg["name"])
|
|
}
|
|
if pkg["version"] != "v1.0.0" {
|
|
t.Errorf("expected version 'v1.0.0', got %v", pkg["version"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPackageWriteDelete(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var gotMethod string
|
|
var gotPath string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
gotMethod = r.Method
|
|
gotPath = r.URL.RawPath
|
|
if gotPath == "" {
|
|
gotPath = r.URL.Path
|
|
}
|
|
mu.Unlock()
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
origHost := flag.Host
|
|
flag.Host = srv.URL
|
|
defer func() { flag.Host = origHost }()
|
|
|
|
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
|
|
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]any{
|
|
"method": "delete",
|
|
"owner": "test-org",
|
|
"type": "container",
|
|
"name": "myrepo/myimage",
|
|
"version": "v1.0.0",
|
|
}
|
|
|
|
result, err := packageWriteFn(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("packageWriteFn() error: %v", err)
|
|
}
|
|
if result.IsError {
|
|
t.Fatal("packageWriteFn() returned error result")
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if gotMethod != "DELETE" {
|
|
t.Errorf("expected DELETE method, got %q", gotMethod)
|
|
}
|
|
wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage/v1.0.0"
|
|
if gotPath != wantPath {
|
|
t.Errorf("request path = %q, want %q", gotPath, wantPath)
|
|
}
|
|
}
|
|
|
|
func TestPackageReadUnknownMethod(t *testing.T) {
|
|
ctx := context.Background()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]any{
|
|
"method": "bogus",
|
|
"owner": "test-org",
|
|
}
|
|
if _, err := packageReadFn(ctx, req); err == nil {
|
|
t.Fatal("expected error for unknown method")
|
|
}
|
|
}
|
|
|
|
func TestPackageWriteUnknownMethod(t *testing.T) {
|
|
ctx := context.Background()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]any{
|
|
"method": "bogus",
|
|
"owner": "test-org",
|
|
}
|
|
if _, err := packageWriteFn(ctx, req); err == nil {
|
|
t.Fatal("expected error for unknown method")
|
|
}
|
|
}
|
|
|
|
func TestEscapePackageName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{"simple name", "mypackage", "mypackage"},
|
|
{"raw slash", "myrepo/myimage", "myrepo%2Fmyimage"},
|
|
{"pre-encoded slash", "myrepo%2Fmyimage", "myrepo%2Fmyimage"},
|
|
{"pre-encoded uppercase", "myrepo%2Fmyimage", "myrepo%2Fmyimage"},
|
|
{"multiple slashes", "a/b/c", "a%2Fb%2Fc"},
|
|
{"pre-encoded multiple slashes", "a%2Fb%2Fc", "a%2Fb%2Fc"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := escapePackageName(tt.input)
|
|
if got != tt.expected {
|
|
t.Errorf("escapePackageName(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSlimPackage(t *testing.T) {
|
|
raw := map[string]any{
|
|
"id": float64(1),
|
|
"type": "container",
|
|
"name": "test-repo/test-image",
|
|
"version": "v1.0.0",
|
|
"html_url": "http://example.com/pkg",
|
|
"created_at": "2025-01-01T00:00:00Z",
|
|
"owner": map[string]any{"login": "test-org", "id": float64(2), "email": ""},
|
|
"creator": map[string]any{"login": "admin", "id": float64(1)},
|
|
"repository": map[string]any{"full_name": "test-org/test-repo", "id": float64(3)},
|
|
}
|
|
|
|
slim := slimPackage(raw)
|
|
if slim["owner"] != "test-org" {
|
|
t.Errorf("expected owner 'test-org', got %v", slim["owner"])
|
|
}
|
|
if slim["creator"] != "admin" {
|
|
t.Errorf("expected creator 'admin', got %v", slim["creator"])
|
|
}
|
|
if slim["repository"] != "test-org/test-repo" {
|
|
t.Errorf("expected repository 'test-org/test-repo', got %v", slim["repository"])
|
|
}
|
|
if _, ok := slim["owner"].(map[string]any); ok {
|
|
t.Error("expected owner to be a string, not a map")
|
|
}
|
|
}
|