package transport import ( "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "wikijs-mcp/internal/server" "wikijs-mcp/internal/wikijs" "github.com/modelcontextprotocol/go-sdk/mcp" ) func testLogger() *slog.Logger { return slog.New(slog.DiscardHandler) } func newTestServer(t *testing.T, wikiClient *wikijs.Client) *mcp.Server { t.Helper() return server.New(wikiClient, server.Options{Version: "test", Logger: testLogger()}) } func newTestHandler(t *testing.T, authToken string) http.Handler { t.Helper() fakeWiki := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":{"pages":{"single":{"id":1,"path":"/start","title":"Start","description":"","tags":[],"content":"body","createdAt":"","updatedAt":"","locale":"en"}}}}`)) })) t.Cleanup(fakeWiki.Close) wikiClient, err := wikijs.New(fakeWiki.URL, "key") if err != nil { t.Fatalf("wikijs.New() error = %v", err) } srv := newTestServer(t, wikiClient) return NewHTTPHandler(HTTPOptions{ Path: "/mcp", AuthToken: authToken, Logger: testLogger(), NewServer: func(*http.Request) *mcp.Server { return srv }, }) } func TestHealthzIsOpen(t *testing.T) { ts := httptest.NewServer(newTestHandler(t, "secret")) t.Cleanup(ts.Close) resp, err := http.Get(ts.URL + "/healthz") if err != nil { t.Fatalf("GET /healthz: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("status = %d, want 200", resp.StatusCode) } var body map[string]string if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if body["status"] != "ok" { t.Errorf("body = %v", body) } } func TestMCPRequiresBearerToken(t *testing.T) { ts := httptest.NewServer(newTestHandler(t, "secret")) t.Cleanup(ts.Close) tests := []struct { name string header string want int }{ {name: "missing", want: http.StatusUnauthorized}, {name: "wrong token", header: "Bearer nope", want: http.StatusUnauthorized}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req, _ := http.NewRequest(http.MethodPost, ts.URL+"/mcp", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") if tt.header != "" { req.Header.Set("Authorization", tt.header) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("POST /mcp: %v", err) } defer resp.Body.Close() if resp.StatusCode != tt.want { t.Errorf("status = %d, want %d", resp.StatusCode, tt.want) } if got := resp.Header.Get("WWW-Authenticate"); !strings.Contains(got, "Bearer") { t.Errorf("WWW-Authenticate = %q", got) } }) } } func TestBearerSchemeIsCaseInsensitive(t *testing.T) { ts := httptest.NewServer(newTestHandler(t, "secret")) t.Cleanup(ts.Close) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/mcp", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "bearer secret") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("POST /mcp: %v", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { t.Error("lowercase bearer scheme was rejected") } } func TestMCPWithBearerToken(t *testing.T) { ts := httptest.NewServer(newTestHandler(t, "secret")) t.Cleanup(ts.Close) httpClient := &http.Client{Transport: &bearerRoundTripper{token: "secret"}} cs := connectHTTP(t, ts.URL+"/mcp", httpClient) res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ Name: "get_page_by_id", Arguments: map[string]any{"id": 1}, }) if err != nil { t.Fatalf("CallTool() error = %v", err) } if res.IsError { t.Fatalf("IsError = true: %v", res.Content) } } func TestMCPWithoutAuthToken(t *testing.T) { ts := httptest.NewServer(newTestHandler(t, "")) t.Cleanup(ts.Close) cs := connectHTTP(t, ts.URL+"/mcp", ts.Client()) res, err := cs.ListTools(context.Background(), nil) if err != nil { t.Fatalf("ListTools() error = %v", err) } if len(res.Tools) != 4 { t.Errorf("tools = %d, want 4", len(res.Tools)) } } func TestServeHTTPRefusesNonLoopbackWithoutAuth(t *testing.T) { err := ServeHTTP(context.Background(), HTTPOptions{ Addr: "0.0.0.0:0", Path: "/mcp", Logger: testLogger(), NewServer: func(*http.Request) *mcp.Server { return newBareServer() }, }) if err == nil { t.Fatal("ServeHTTP() error = nil, want refusal") } if !strings.Contains(err.Error(), "non-loopback") { t.Errorf("error = %q, want non-loopback refusal", err) } } func TestServeHTTPShutdown(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) go func() { done <- ServeHTTP(ctx, HTTPOptions{ Addr: "127.0.0.1:0", Path: "/mcp", Logger: testLogger(), NewServer: func(*http.Request) *mcp.Server { return newBareServer() }, }) }() time.Sleep(100 * time.Millisecond) cancel() select { case err := <-done: if err != nil { t.Fatalf("ServeHTTP() error = %v", err) } case <-time.After(5 * time.Second): t.Fatal("ServeHTTP() did not stop after cancellation") } } func newBareServer() *mcp.Server { return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0"}, nil) } func connectHTTP(t *testing.T, endpoint string, httpClient *http.Client) *mcp.ClientSession { t.Helper() client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, nil) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) t.Cleanup(cancel) cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{ Endpoint: endpoint, HTTPClient: httpClient, DisableStandaloneSSE: true, }, nil) if err != nil { t.Fatalf("client connect: %v", err) } t.Cleanup(func() { _ = cs.Close() }) return cs } type bearerRoundTripper struct { token string } func (t *bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) req.Header.Set("Authorization", "Bearer "+t.token) return http.DefaultTransport.RoundTrip(req) }