registry/internal/api/handlers/v0/edit_test.go
adam jones 9d9a02c57f
Database implementation cleanup (#335)
## Motivation and Context

The database logic was getting a bit unwieldy and fragile - especially
because business logic that should really have lived at the registry
service level was being pushed into the persistence level.

This PR tightens the interface, simplifies the code there, as well as
rips out a bunch of mocks etc. we don't need.

## How Has This Been Tested?

Locally everything works

## Breaking Changes

The list endpoint was surprisingly always filtering for isLatest only,
which seems like a bug? I have removed this filter.
2025-09-02 13:37:28 +01:00

329 lines
11 KiB
Go

package v0_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/stretchr/testify/assert"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/service"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
)
func TestEditServerEndpoint(t *testing.T) {
// Create registry service and insert a common test server
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
// Publish a test server that will be used across test cases
testServer := apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Original test server",
Status: model.StatusActive,
Repository: model.Repository{
URL: "https://github.com/domdomegg/test-server",
Source: "github",
ID: "domdomegg/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
}
published, err := registryService.Publish(testServer)
assert.NoError(t, err)
assert.NotNil(t, published)
assert.NotNil(t, published.Meta)
assert.NotNil(t, published.Meta.IOModelContextProtocolRegistry)
testServerID := published.Meta.IOModelContextProtocolRegistry.ID
// Publish a second server for permission testing
otherServer := apiv0.ServerJSON{
Name: "io.github.other/test-server",
Description: "Other test server",
Status: model.StatusActive,
Repository: model.Repository{
URL: "https://github.com/other/test-server",
Source: "github",
ID: "other/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
}
otherPublished, err := registryService.Publish(otherServer)
assert.NoError(t, err)
assert.NotNil(t, otherPublished)
assert.NotNil(t, otherPublished.Meta)
assert.NotNil(t, otherPublished.Meta.IOModelContextProtocolRegistry)
otherServerID := otherPublished.Meta.IOModelContextProtocolRegistry.ID
// Publish a deleted server for undelete testing
deletedServer := apiv0.ServerJSON{
Name: "io.github.domdomegg/deleted-server",
Description: "Deleted test server",
Status: model.StatusDeleted,
Repository: model.Repository{
URL: "https://github.com/domdomegg/deleted-server",
Source: "github",
ID: "domdomegg/deleted-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
}
deletedPublished, err := registryService.Publish(deletedServer)
assert.NoError(t, err)
assert.NotNil(t, deletedPublished)
assert.NotNil(t, deletedPublished.Meta)
assert.NotNil(t, deletedPublished.Meta.IOModelContextProtocolRegistry)
deletedServerID := deletedPublished.Meta.IOModelContextProtocolRegistry.ID
testCases := []struct {
name string
authHeader string
requestBody interface{}
serverID string
expectedStatus int
expectedError string
}{
{
name: "successful edit with valid token and permissions",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
AuthMethod: auth.MethodGitHubAT,
AuthMethodSubject: "domdomegg",
Permissions: []auth.Permission{
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.domdomegg/*"},
},
})
return "Bearer " + token
}(),
requestBody: apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Updated test server",
Status: model.StatusDeprecated,
Repository: model.Repository{
URL: "https://github.com/domdomegg/test-server",
Source: "github",
ID: "domdomegg/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.1",
},
},
serverID: testServerID,
expectedStatus: http.StatusOK,
},
{
name: "missing authorization header",
authHeader: "",
requestBody: apiv0.ServerJSON{},
serverID: testServerID,
expectedStatus: 422,
expectedError: "required header parameter is missing",
},
{
name: "invalid authorization header format",
authHeader: "InvalidFormat token123",
requestBody: apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
serverID: testServerID,
expectedStatus: http.StatusUnauthorized,
expectedError: "Unauthorized",
},
{
name: "invalid token",
authHeader: "Bearer invalid-token",
requestBody: apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
serverID: testServerID,
expectedStatus: http.StatusUnauthorized,
expectedError: "Unauthorized",
},
{
name: "permission denied - no edit permissions",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
AuthMethod: auth.MethodGitHubAT,
AuthMethodSubject: "domdomegg",
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.domdomegg/*"},
},
})
return "Bearer " + token
}(),
requestBody: apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Updated test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
serverID: testServerID,
expectedStatus: http.StatusForbidden,
expectedError: "Forbidden",
},
{
name: "permission denied - wrong resource",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
AuthMethod: auth.MethodGitHubAT,
AuthMethodSubject: "domdomegg",
Permissions: []auth.Permission{
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.domdomegg/*"},
},
})
return "Bearer " + token
}(),
requestBody: apiv0.ServerJSON{
Name: "io.github.other/test-server",
Description: "Updated test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
serverID: otherServerID,
expectedStatus: http.StatusForbidden,
expectedError: "Forbidden",
},
{
name: "server not found",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
AuthMethod: auth.MethodGitHubAT,
AuthMethodSubject: "domdomegg",
Permissions: []auth.Permission{
{Action: auth.PermissionActionEdit, ResourcePattern: "io.github.domdomegg/*"},
},
})
return "Bearer " + token
}(),
requestBody: apiv0.ServerJSON{
Name: "io.github.domdomegg/nonexistent-server",
Description: "Updated test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
serverID: "550e8400-e29b-41d4-a716-446655440999", // Non-existent ID
expectedStatus: http.StatusNotFound,
expectedError: "Not Found",
},
{
name: "validation error - invalid server name",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
AuthMethod: auth.MethodGitHubAT,
AuthMethodSubject: "domdomegg",
Permissions: []auth.Permission{
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
},
})
return "Bearer " + token
}(),
requestBody: apiv0.ServerJSON{
Name: "invalid-name-format", // Missing namespace/name format
Description: "Test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
serverID: testServerID,
expectedStatus: http.StatusBadRequest,
expectedError: "Bad Request",
},
{
name: "cannot undelete server",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
AuthMethod: auth.MethodGitHubAT,
AuthMethodSubject: "domdomegg",
Permissions: []auth.Permission{
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
},
})
return "Bearer " + token
}(),
requestBody: apiv0.ServerJSON{
Name: "io.github.domdomegg/deleted-server",
Description: "Trying to undelete server",
Status: model.StatusActive,
Repository: model.Repository{
URL: "https://github.com/domdomegg/deleted-server",
Source: "github",
ID: "domdomegg/deleted-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.1",
},
},
serverID: deletedServerID,
expectedStatus: http.StatusBadRequest,
expectedError: "Cannot change status of deleted server",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create Huma API
mux := http.NewServeMux()
humaConfig := huma.DefaultConfig("Test API", "1.0.0")
api := humago.New(mux, humaConfig)
// Register edit endpoints
cfg := &config.Config{
JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c",
}
v0.RegisterEditEndpoints(api, registryService, cfg)
// Create request body
var requestBody []byte
var err error
if str, ok := tc.requestBody.(string); ok {
requestBody = []byte(str)
} else {
requestBody, err = json.Marshal(tc.requestBody)
assert.NoError(t, err)
}
// Create request
req := httptest.NewRequest(http.MethodPut, "/v0/servers/"+tc.serverID, bytes.NewReader(requestBody))
req.Header.Set("Content-Type", "application/json")
if tc.authHeader != "" {
req.Header.Set("Authorization", tc.authHeader)
}
// Create response recorder
w := httptest.NewRecorder()
// Call the endpoint
mux.ServeHTTP(w, req)
// Check status code
assert.Equal(t, tc.expectedStatus, w.Code)
// Check error message if expected
if tc.expectedError != "" {
assert.Contains(t, w.Body.String(), tc.expectedError)
}
// No mock assertions needed with real service
})
}
}