diff --git a/go.mod b/go.mod index 8a59528..73f4312 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.0 require ( github.com/caarlos0/env/v11 v11.3.1 github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.10.0 github.com/swaggo/files v1.0.1 github.com/swaggo/http-swagger v1.3.4 go.mongodb.org/mongo-driver v1.17.3 @@ -12,6 +13,7 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect @@ -21,6 +23,8 @@ require ( github.com/klauspost/compress v1.16.7 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/montanaflynn/stats v0.7.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/swaggo/swag v1.16.4 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect diff --git a/go.sum b/go.sum index daa0051..5c7a5a6 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= diff --git a/internal/api/handlers/v0/health_test.go b/internal/api/handlers/v0/health_test.go new file mode 100644 index 0000000..d0ddece --- /dev/null +++ b/internal/api/handlers/v0/health_test.go @@ -0,0 +1,113 @@ +package v0 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/modelcontextprotocol/registry/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestHealthHandler(t *testing.T) { + // Test cases + testCases := []struct { + name string + config *config.Config + expectedStatus int + expectedBody HealthResponse + }{ + { + name: "returns health status with github client id", + config: &config.Config{ + GithubClientID: "test-github-client-id", + }, + expectedStatus: http.StatusOK, + expectedBody: HealthResponse{ + Status: "ok", + GitHubClientId: "test-github-client-id", + }, + }, + { + name: "works with empty github client id", + config: &config.Config{ + GithubClientID: "", + }, + expectedStatus: http.StatusOK, + expectedBody: HealthResponse{ + Status: "ok", + GitHubClientId: "", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create handler with the test config + handler := HealthHandler(tc.config) + + // Create request + req, err := http.NewRequest("GET", "/health", nil) + if err != nil { + t.Fatal(err) + } + + // Create response recorder + rr := httptest.NewRecorder() + + // Call the handler + handler.ServeHTTP(rr, req) + + // Check status code + assert.Equal(t, tc.expectedStatus, rr.Code) + + // Check content type + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + + // Parse response body + var resp HealthResponse + err = json.NewDecoder(rr.Body).Decode(&resp) + assert.NoError(t, err) + + // Check the response body + assert.Equal(t, tc.expectedBody, resp) + }) + } +} + +// TestHealthHandlerIntegration tests the handler with actual HTTP requests +func TestHealthHandlerIntegration(t *testing.T) { + // Create test server + cfg := &config.Config{ + GithubClientID: "integration-test-client-id", + } + + server := httptest.NewServer(HealthHandler(cfg)) + defer server.Close() + + // Send request to the test server + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer resp.Body.Close() + + // Check status code + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Check content type + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + + // Parse response body + var healthResp HealthResponse + err = json.NewDecoder(resp.Body).Decode(&healthResp) + assert.NoError(t, err) + + // Check the response body + expectedResp := HealthResponse{ + Status: "ok", + GitHubClientId: "integration-test-client-id", + } + assert.Equal(t, expectedResp, healthResp) +} diff --git a/internal/api/handlers/v0/publish_test.go b/internal/api/handlers/v0/publish_test.go new file mode 100644 index 0000000..508bc36 --- /dev/null +++ b/internal/api/handlers/v0/publish_test.go @@ -0,0 +1,479 @@ +package v0 + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/modelcontextprotocol/registry/internal/auth" + "github.com/modelcontextprotocol/registry/internal/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockRegistryService is a mock implementation of the RegistryService interface +type MockRegistryService struct { + mock.Mock +} + +func (m *MockRegistryService) List(cursor string, limit int) ([]model.Server, string, error) { + args := m.Called(cursor, limit) + return args.Get(0).([]model.Server), args.String(1), args.Error(2) +} + +func (m *MockRegistryService) GetByID(id string) (*model.ServerDetail, error) { + args := m.Called(id) + return args.Get(0).(*model.ServerDetail), args.Error(1) +} + +func (m *MockRegistryService) Publish(serverDetail *model.ServerDetail) error { + args := m.Called(serverDetail) + return args.Error(0) +} + +// MockAuthService is a mock implementation of the auth.Service interface +type MockAuthService struct { + mock.Mock +} + +func (m *MockAuthService) StartAuthFlow(ctx context.Context, method model.AuthMethod, repoRef string) (map[string]string, string, error) { + args := m.Called(ctx, method, repoRef) + return args.Get(0).(map[string]string), args.String(1), args.Error(2) +} + +func (m *MockAuthService) CheckAuthStatus(ctx context.Context, statusToken string) (string, error) { + args := m.Called(ctx, statusToken) + return args.String(0), args.Error(1) +} + +func (m *MockAuthService) ValidateAuth(ctx context.Context, authentication model.Authentication) (bool, error) { + args := m.Called(ctx, authentication) + return args.Bool(0), args.Error(1) +} + +func TestPublishHandler(t *testing.T) { + testCases := []struct { + name string + method string + requestBody interface{} + authHeader string + setupMocks func(*MockRegistryService, *MockAuthService) + expectedStatus int + expectedResponse map[string]string + expectedError string + }{ + { + name: "successful publish with GitHub auth", + method: http.MethodPost, + requestBody: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "io.github.example/test-server", + Description: "A test server", + Repository: model.Repository{ + URL: "https://github.com/example/test-server", + Source: "github", + ID: "example/test-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, + }, + }, + authHeader: "Bearer github_token_123", + setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { + authSvc.On("ValidateAuth", mock.Anything, model.Authentication{ + Method: model.AuthMethodGitHub, + Token: "github_token_123", + RepoRef: "io.github.example/test-server", + }).Return(true, nil) + registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) + }, + expectedStatus: http.StatusCreated, + expectedResponse: map[string]string{ + "message": "Server publication successful", + "id": "test-id", + }, + }, + { + name: "successful publish with no auth (AuthMethodNone)", + method: http.MethodPost, + requestBody: model.ServerDetail{ + Server: model.Server{ + ID: "test-id-2", + Name: "example/test-server", + Description: "A test server without auth", + Repository: model.Repository{ + URL: "https://example.com/test-server", + Source: "example", + ID: "example/test-server", + }, + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, + }, + }, + authHeader: "Bearer some_token", + setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { + authSvc.On("ValidateAuth", mock.Anything, model.Authentication{ + Method: model.AuthMethodNone, + Token: "some_token", + RepoRef: "example/test-server", + }).Return(true, nil) + registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) + }, + expectedStatus: http.StatusCreated, + expectedResponse: map[string]string{ + "message": "Server publication successful", + "id": "test-id-2", + }, + }, + { + name: "method not allowed", + method: http.MethodGet, + requestBody: nil, + authHeader: "", + setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) {}, + expectedStatus: http.StatusMethodNotAllowed, + expectedError: "Method not allowed", + }, + { + name: "missing request body", + method: http.MethodPost, + requestBody: "", + authHeader: "", + setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "Invalid request payload:", + }, + { + name: "missing server name", + method: http.MethodPost, + requestBody: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "", // Missing name + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, + }, + }, + authHeader: "", + setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "Name is required", + }, + { + name: "missing version", + method: http.MethodPost, + requestBody: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "", // Missing version + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, + }, + }, + authHeader: "", + setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "Version is required", + }, + { + name: "missing authorization header", + method: http.MethodPost, + requestBody: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, + }, + }, + authHeader: "", // Missing auth header + setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) {}, + expectedStatus: http.StatusUnauthorized, + expectedError: "Authorization header is required", + }, + { + name: "authentication required error", + method: http.MethodPost, + requestBody: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, + }, + }, + authHeader: "Bearer token", + setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { + authSvc.On("ValidateAuth", mock.Anything, mock.Anything).Return(false, auth.ErrAuthRequired) + }, + expectedStatus: http.StatusUnauthorized, + expectedError: "Authentication is required for publishing", + }, + { + name: "authentication failed", + method: http.MethodPost, + requestBody: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, + }, + }, + authHeader: "Bearer invalid_token", + setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { + authSvc.On("ValidateAuth", mock.Anything, mock.Anything).Return(false, nil) + }, + expectedStatus: http.StatusUnauthorized, + expectedError: "Invalid authentication credentials", + }, + { + name: "registry service error", + method: http.MethodPost, + requestBody: model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, + }, + }, + authHeader: "Bearer token", + setupMocks: func(registry *MockRegistryService, authSvc *MockAuthService) { + authSvc.On("ValidateAuth", mock.Anything, mock.Anything).Return(true, nil) + registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(assert.AnError) + }, + expectedStatus: http.StatusInternalServerError, + expectedError: "Failed to publish server details:", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create mocks + mockRegistry := new(MockRegistryService) + mockAuthService := new(MockAuthService) + + // Setup mocks + tc.setupMocks(mockRegistry, mockAuthService) + + // Create handler + handler := PublishHandler(mockRegistry, mockAuthService) + + // Prepare request body + var requestBody []byte + if tc.requestBody != nil { + var err error + requestBody, err = json.Marshal(tc.requestBody) + assert.NoError(t, err) + } + + // Create request + req, err := http.NewRequest(tc.method, "/publish", bytes.NewBuffer(requestBody)) + assert.NoError(t, err) + + // Set auth header if provided + if tc.authHeader != "" { + req.Header.Set("Authorization", tc.authHeader) + } + + // Create response recorder + rr := httptest.NewRecorder() + + // Call the handler + handler.ServeHTTP(rr, req) + + // Check status code + assert.Equal(t, tc.expectedStatus, rr.Code) + + if tc.expectedResponse != nil { + // Check content type for successful responses + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + + // Parse and verify response body + var response map[string]string + err = json.NewDecoder(rr.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, tc.expectedResponse, response) + } + + if tc.expectedError != "" { + // Check that the error message is contained in the response + assert.Contains(t, rr.Body.String(), tc.expectedError) + } + + // Assert that all expectations were met + mockRegistry.AssertExpectations(t) + mockAuthService.AssertExpectations(t) + }) + } +} + +func TestPublishHandlerBearerTokenParsing(t *testing.T) { + testCases := []struct { + name string + authHeader string + expectedToken string + }{ + { + name: "bearer token with Bearer prefix", + authHeader: "Bearer github_token_123", + expectedToken: "github_token_123", + }, + { + name: "bearer token with bearer prefix (lowercase)", + authHeader: "bearer github_token_123", + expectedToken: "github_token_123", + }, + { + name: "token without Bearer prefix", + authHeader: "github_token_123", + expectedToken: "github_token_123", + }, + { + name: "mixed case Bearer prefix", + authHeader: "BeArEr github_token_123", + expectedToken: "github_token_123", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockRegistry := new(MockRegistryService) + mockAuthService := new(MockAuthService) + + // Setup mock to capture the actual token passed + mockAuthService.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { + return auth.Token == tc.expectedToken + })).Return(true, nil) + mockRegistry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) + + handler := PublishHandler(mockRegistry, mockAuthService) + + serverDetail := model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: "test-server", + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, + }, + } + + requestBody, err := json.Marshal(serverDetail) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, "/publish", bytes.NewBuffer(requestBody)) + assert.NoError(t, err) + req.Header.Set("Authorization", tc.authHeader) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusCreated, rr.Code) + mockAuthService.AssertExpectations(t) + }) + } +} + +func TestPublishHandlerAuthMethodSelection(t *testing.T) { + testCases := []struct { + name string + serverName string + expectedAuthMethod model.AuthMethod + }{ + { + name: "GitHub prefix triggers GitHub auth", + serverName: "io.github.example/test-server", + expectedAuthMethod: model.AuthMethodGitHub, + }, + { + name: "non-GitHub prefix uses no auth", + serverName: "example.com/test-server", + expectedAuthMethod: model.AuthMethodNone, + }, + { + name: "empty prefix uses no auth", + serverName: "test-server", + expectedAuthMethod: model.AuthMethodNone, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockRegistry := new(MockRegistryService) + mockAuthService := new(MockAuthService) + + // Setup mock to capture the auth method + mockAuthService.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool { + return auth.Method == tc.expectedAuthMethod + })).Return(true, nil) + mockRegistry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil) + + handler := PublishHandler(mockRegistry, mockAuthService) + + serverDetail := model.ServerDetail{ + Server: model.Server{ + ID: "test-id", + Name: tc.serverName, + Description: "A test server", + VersionDetail: model.VersionDetail{ + Version: "1.0.0", + ReleaseDate: "2025-05-25T00:00:00Z", + IsLatest: true, + }, + }, + } + + requestBody, err := json.Marshal(serverDetail) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, "/publish", bytes.NewBuffer(requestBody)) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer test_token") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusCreated, rr.Code) + mockAuthService.AssertExpectations(t) + }) + } +}