http_test.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. package transport
  2. import (
  3. "context"
  4. "encoding/json"
  5. "log/slog"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "time"
  11. "gogs.netdome.biz/paul/go-wikijs-mcp/internal/server"
  12. "gogs.netdome.biz/paul/go-wikijs-mcp/internal/wikijs"
  13. "github.com/modelcontextprotocol/go-sdk/mcp"
  14. )
  15. func testLogger() *slog.Logger {
  16. return slog.New(slog.DiscardHandler)
  17. }
  18. func newTestServer(t *testing.T, wikiClient *wikijs.Client) *mcp.Server {
  19. t.Helper()
  20. return server.New(wikiClient, server.Options{Version: "test", Logger: testLogger()})
  21. }
  22. func newTestHandler(t *testing.T, authToken string) http.Handler {
  23. t.Helper()
  24. fakeWiki := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  25. w.Header().Set("Content-Type", "application/json")
  26. _, _ = w.Write([]byte(`{"data":{"pages":{"single":{"id":1,"path":"/start","title":"Start","description":"","tags":[],"content":"body","createdAt":"","updatedAt":"","locale":"en"}}}}`))
  27. }))
  28. t.Cleanup(fakeWiki.Close)
  29. wikiClient, err := wikijs.New(fakeWiki.URL, "key")
  30. if err != nil {
  31. t.Fatalf("wikijs.New() error = %v", err)
  32. }
  33. srv := newTestServer(t, wikiClient)
  34. return NewHTTPHandler(HTTPOptions{
  35. Path: "/mcp",
  36. AuthToken: authToken,
  37. Logger: testLogger(),
  38. NewServer: func(*http.Request) *mcp.Server { return srv },
  39. })
  40. }
  41. func TestHealthzIsOpen(t *testing.T) {
  42. ts := httptest.NewServer(newTestHandler(t, "secret"))
  43. t.Cleanup(ts.Close)
  44. resp, err := http.Get(ts.URL + "/healthz")
  45. if err != nil {
  46. t.Fatalf("GET /healthz: %v", err)
  47. }
  48. defer resp.Body.Close()
  49. if resp.StatusCode != http.StatusOK {
  50. t.Errorf("status = %d, want 200", resp.StatusCode)
  51. }
  52. var body map[string]string
  53. if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
  54. t.Fatalf("decode body: %v", err)
  55. }
  56. if body["status"] != "ok" {
  57. t.Errorf("body = %v", body)
  58. }
  59. }
  60. func TestMCPRequiresBearerToken(t *testing.T) {
  61. ts := httptest.NewServer(newTestHandler(t, "secret"))
  62. t.Cleanup(ts.Close)
  63. tests := []struct {
  64. name string
  65. header string
  66. want int
  67. }{
  68. {name: "missing", want: http.StatusUnauthorized},
  69. {name: "wrong token", header: "Bearer nope", want: http.StatusUnauthorized},
  70. }
  71. for _, tt := range tests {
  72. t.Run(tt.name, func(t *testing.T) {
  73. req, _ := http.NewRequest(http.MethodPost, ts.URL+"/mcp", strings.NewReader(`{}`))
  74. req.Header.Set("Content-Type", "application/json")
  75. if tt.header != "" {
  76. req.Header.Set("Authorization", tt.header)
  77. }
  78. resp, err := http.DefaultClient.Do(req)
  79. if err != nil {
  80. t.Fatalf("POST /mcp: %v", err)
  81. }
  82. defer resp.Body.Close()
  83. if resp.StatusCode != tt.want {
  84. t.Errorf("status = %d, want %d", resp.StatusCode, tt.want)
  85. }
  86. if got := resp.Header.Get("WWW-Authenticate"); !strings.Contains(got, "Bearer") {
  87. t.Errorf("WWW-Authenticate = %q", got)
  88. }
  89. })
  90. }
  91. }
  92. func TestBearerSchemeIsCaseInsensitive(t *testing.T) {
  93. ts := httptest.NewServer(newTestHandler(t, "secret"))
  94. t.Cleanup(ts.Close)
  95. req, _ := http.NewRequest(http.MethodPost, ts.URL+"/mcp", strings.NewReader(`{}`))
  96. req.Header.Set("Content-Type", "application/json")
  97. req.Header.Set("Authorization", "bearer secret")
  98. resp, err := http.DefaultClient.Do(req)
  99. if err != nil {
  100. t.Fatalf("POST /mcp: %v", err)
  101. }
  102. defer resp.Body.Close()
  103. if resp.StatusCode == http.StatusUnauthorized {
  104. t.Error("lowercase bearer scheme was rejected")
  105. }
  106. }
  107. func TestMCPWithBearerToken(t *testing.T) {
  108. ts := httptest.NewServer(newTestHandler(t, "secret"))
  109. t.Cleanup(ts.Close)
  110. httpClient := &http.Client{Transport: &bearerRoundTripper{token: "secret"}}
  111. cs := connectHTTP(t, ts.URL+"/mcp", httpClient)
  112. res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
  113. Name: "get_page_by_id",
  114. Arguments: map[string]any{"id": 1},
  115. })
  116. if err != nil {
  117. t.Fatalf("CallTool() error = %v", err)
  118. }
  119. if res.IsError {
  120. t.Fatalf("IsError = true: %v", res.Content)
  121. }
  122. }
  123. func TestMCPWithoutAuthToken(t *testing.T) {
  124. ts := httptest.NewServer(newTestHandler(t, ""))
  125. t.Cleanup(ts.Close)
  126. cs := connectHTTP(t, ts.URL+"/mcp", ts.Client())
  127. res, err := cs.ListTools(context.Background(), nil)
  128. if err != nil {
  129. t.Fatalf("ListTools() error = %v", err)
  130. }
  131. if len(res.Tools) != 4 {
  132. t.Errorf("tools = %d, want 4", len(res.Tools))
  133. }
  134. }
  135. func TestServeHTTPRefusesNonLoopbackWithoutAuth(t *testing.T) {
  136. err := ServeHTTP(context.Background(), HTTPOptions{
  137. Addr: "0.0.0.0:0",
  138. Path: "/mcp",
  139. Logger: testLogger(),
  140. NewServer: func(*http.Request) *mcp.Server { return newBareServer() },
  141. })
  142. if err == nil {
  143. t.Fatal("ServeHTTP() error = nil, want refusal")
  144. }
  145. if !strings.Contains(err.Error(), "non-loopback") {
  146. t.Errorf("error = %q, want non-loopback refusal", err)
  147. }
  148. }
  149. func TestServeHTTPShutdown(t *testing.T) {
  150. ctx, cancel := context.WithCancel(context.Background())
  151. defer cancel()
  152. done := make(chan error, 1)
  153. go func() {
  154. done <- ServeHTTP(ctx, HTTPOptions{
  155. Addr: "127.0.0.1:0",
  156. Path: "/mcp",
  157. Logger: testLogger(),
  158. NewServer: func(*http.Request) *mcp.Server { return newBareServer() },
  159. })
  160. }()
  161. time.Sleep(100 * time.Millisecond)
  162. cancel()
  163. select {
  164. case err := <-done:
  165. if err != nil {
  166. t.Fatalf("ServeHTTP() error = %v", err)
  167. }
  168. case <-time.After(5 * time.Second):
  169. t.Fatal("ServeHTTP() did not stop after cancellation")
  170. }
  171. }
  172. func newBareServer() *mcp.Server {
  173. return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0"}, nil)
  174. }
  175. func connectHTTP(t *testing.T, endpoint string, httpClient *http.Client) *mcp.ClientSession {
  176. t.Helper()
  177. client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, nil)
  178. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  179. t.Cleanup(cancel)
  180. cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{
  181. Endpoint: endpoint,
  182. HTTPClient: httpClient,
  183. DisableStandaloneSSE: true,
  184. }, nil)
  185. if err != nil {
  186. t.Fatalf("client connect: %v", err)
  187. }
  188. t.Cleanup(func() { _ = cs.Close() })
  189. return cs
  190. }
  191. type bearerRoundTripper struct {
  192. token string
  193. }
  194. func (t *bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
  195. req = req.Clone(req.Context())
  196. req.Header.Set("Authorization", "Bearer "+t.token)
  197. return http.DefaultTransport.RoundTrip(req)
  198. }