auth: New auth flow (#273)
Fixes #264. Adds endpoints: ``` /v0/auth/github # exchange a github token for a registry token that gives you access to namespaces io.github.username and io.github.org for your orgs /v0/auth/none # get a registry token for namespace io.modelcontextprotocol.anonymous (only for testing/local dev purposes) ``` Updates `/v0/publish` to accept one of these registry tokens. ## Motivation and Context See #264 ## How Has This Been Tested? - Running locally and getting a token from `/v0/auth/none` and then publishing to `/v0/publish` - Updating the integration tests that get a token and then publish, and them passing ## Breaking Changes This is a breaking change to the auth API. This is fine for registry given it's not deployed yet. ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed ## Additional context <!-- Add any other context, implementation notes, or design decisions --> --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: adam jones <domdomegg@users.noreply.github.com>
This commit is contained in:
parent
95bf1e3e30
commit
c733cedaf8
36 changed files with 1759 additions and 1537 deletions
10
.env.example
10
.env.example
|
|
@ -17,4 +17,12 @@ MCP_REGISTRY_VERSION=dev
|
|||
|
||||
# GitHub OAuth configuration
|
||||
MCP_REGISTRY_GITHUB_CLIENT_ID=
|
||||
MCP_REGISTRY_GITHUB_CLIENT_SECRET=
|
||||
MCP_REGISTRY_GITHUB_CLIENT_SECRET=
|
||||
|
||||
# JWT configuration
|
||||
# This should be a 32-byte Ed25519 seed (not the full private key). Generate a new seed with: `openssl rand -hex 32`
|
||||
MCP_REGISTRY_JWT_PRIVATE_KEY=bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c
|
||||
|
||||
# Anonymous authentication (for development/testing only)
|
||||
# When enabled, allows anyone to get tokens for publishing to io.modelcontextprotocol.anonymous/* namespace
|
||||
MCP_REGISTRY_ENABLE_ANONYMOUS_AUTH=false
|
||||
|
|
@ -12,7 +12,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/registry/internal/api"
|
||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/database"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
|
|
@ -92,11 +91,8 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
// Initialize authentication services
|
||||
authService := auth.NewAuthService(cfg)
|
||||
|
||||
// Initialize HTTP server
|
||||
server := api.NewServer(cfg, registryService, authService)
|
||||
server := api.NewServer(cfg, registryService)
|
||||
|
||||
// Start server in a goroutine so it doesn't block signal handling
|
||||
go func() {
|
||||
|
|
|
|||
|
|
@ -7,4 +7,6 @@ config:
|
|||
secure: v1:68BmTaKlZ+v77IDj:vVeMsAq/8QoCzkLzgo8MC8F5ALcC5TydWiqZ2NmnnFRpfF3MRPV69Os=
|
||||
gcp:credentials:
|
||||
secure: v1:hyZWlpeMTFDnMcz2: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
|
||||
mcp-registry:jwtPrivateKey:
|
||||
secure: v1:0QwJl5e504ECQfjb:j8b4v1KxxqS8g9E8JTL0Wq5EEDbk/xPUglyg4/hyuL8go+pH2EE6skjf+7D5aAsld1SDRGlhXQBOZIVnjLBffsVN7cBhVlKy2qwTCC6QH5Q=
|
||||
encryptionsalt: v1:0funtAX4m9k=:v1:yMCnBXyBO+q4+/yy:AqWJTzwwIWXmUK0JGCzqbeg0RUr8Jw==
|
||||
|
|
|
|||
|
|
@ -7,4 +7,6 @@ config:
|
|||
secure: v1:BpUPXf7aQcG8qHF9:YJxKbhWi5ZQSTdNGrdhzzCGz7sWXKhmNe+yrTls2cKC3/w==
|
||||
gcp:credentials:
|
||||
secure: v1:RaHpGsBp37XO/EhJ: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
|
||||
mcp-registry:jwtPrivateKey:
|
||||
secure: v1:0NSeI0qWrdcHeVfh:P56IEP/700/e839TSbF7Ns2j2orZnD5cRNXohjxCPOKyIDYn+0bmiTuk8pyFnFk8WibS/w7M2FFgv3/BL0Djo0XMVe9tq+7HjLN4tPEJnCU=
|
||||
encryptionsalt: v1:EKBwmTmss1c=:v1:JLd4a7cM0X8Jroh+:z9/q6RSCEFTDzMV6X6h5Tpbw0tnpkA==
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import (
|
|||
func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, environment string) (*corev1.Service, error) {
|
||||
conf := config.New(ctx, "mcp-registry")
|
||||
githubClientId := conf.Require("githubClientId")
|
||||
githubClientSecret := conf.RequireSecret("githubClientSecret")
|
||||
|
||||
// Create Secret with sensitive configuration
|
||||
secret, err := corev1.NewSecret(ctx, "mcp-registry-secrets", &corev1.SecretArgs{
|
||||
|
|
@ -28,7 +27,8 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env
|
|||
},
|
||||
},
|
||||
StringData: pulumi.StringMap{
|
||||
"GITHUB_CLIENT_SECRET": githubClientSecret,
|
||||
"GITHUB_CLIENT_SECRET": conf.RequireSecret("githubClientSecret"),
|
||||
"JWT_PRIVATE_KEY": conf.RequireSecret("jwtPrivateKey"),
|
||||
},
|
||||
Type: pulumi.String("Opaque"),
|
||||
}, pulumi.Provider(cluster.Provider))
|
||||
|
|
@ -89,6 +89,15 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env
|
|||
},
|
||||
},
|
||||
},
|
||||
&corev1.EnvVarArgs{
|
||||
Name: pulumi.String("MCP_REGISTRY_JWT_PRIVATE_KEY"),
|
||||
ValueFrom: &corev1.EnvVarSourceArgs{
|
||||
SecretKeyRef: &corev1.SecretKeySelectorArgs{
|
||||
Name: secret.Metadata.Name(),
|
||||
Key: pulumi.String("JWT_PRIVATE_KEY"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
LivenessProbe: &corev1.ProbeArgs{
|
||||
HttpGet: &corev1.HTTPGetActionArgs{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ services:
|
|||
- MCP_REGISTRY_ENVIRONMENT=${MCP_REGISTRY_ENVIRONMENT:-test}
|
||||
- MCP_REGISTRY_GITHUB_CLIENT_ID=${MCP_REGISTRY_GITHUB_CLIENT_ID}
|
||||
- MCP_REGISTRY_GITHUB_CLIENT_SECRET=${MCP_REGISTRY_GITHUB_CLIENT_SECRET}
|
||||
- MCP_REGISTRY_JWT_PRIVATE_KEY=${MCP_REGISTRY_JWT_PRIVATE_KEY:-8103179d8ef955f6d3de6d6217224a909ec4060529dfeb1d4ca5a994537658cd}
|
||||
- MCP_REGISTRY_ENABLE_ANONYMOUS_AUTH=${MCP_REGISTRY_ENABLE_ANONYMOUS_AUTH:-true}
|
||||
ports:
|
||||
- 8080:8080
|
||||
restart: "unless-stopped"
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -13,6 +13,7 @@ require (
|
|||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -5,6 +5,8 @@ github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRL
|
|||
github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
package v0
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
// StartAuthInput represents the input for starting an auth flow
|
||||
type StartAuthInput struct {
|
||||
Body struct {
|
||||
Method string `json:"method" doc:"Authentication method" example:"github"`
|
||||
RepoRef string `json:"repo_ref" doc:"Repository reference" example:"owner/repo"`
|
||||
}
|
||||
}
|
||||
|
||||
// StartAuthBody represents the auth flow start response body
|
||||
type StartAuthBody struct {
|
||||
AuthFlowInfo map[string]string `json:"auth_flow_info" doc:"Authentication flow information"`
|
||||
StatusToken string `json:"status_token" doc:"Token to check auth status"`
|
||||
}
|
||||
|
||||
// CheckAuthStatusInput represents the input for checking auth status
|
||||
type CheckAuthStatusInput struct {
|
||||
StatusToken string `path:"token" doc:"Status token from auth flow start"`
|
||||
}
|
||||
|
||||
// CheckAuthStatusBody represents the auth status response body
|
||||
type CheckAuthStatusBody struct {
|
||||
Status string `json:"status" doc:"Authentication status" example:"pending"`
|
||||
}
|
||||
|
||||
// RegisterAuthEndpoints registers all auth-related endpoints
|
||||
func RegisterAuthEndpoints(api huma.API, authService auth.Service) {
|
||||
// Start auth flow endpoint
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "start-auth",
|
||||
Method: http.MethodPost,
|
||||
Path: "/v0/auth/start",
|
||||
Summary: "Start authentication flow",
|
||||
Description: "Start an authentication flow for publishing servers",
|
||||
Tags: []string{"auth"},
|
||||
}, func(ctx context.Context, input *StartAuthInput) (*Response[StartAuthBody], error) {
|
||||
// Validate required fields
|
||||
if input.Body.Method == "" {
|
||||
return nil, huma.Error400BadRequest("Auth method is required")
|
||||
}
|
||||
if input.Body.RepoRef == "" {
|
||||
return nil, huma.Error400BadRequest("Repository reference is required")
|
||||
}
|
||||
|
||||
// Convert string method to AuthMethod type
|
||||
var authMethod model.AuthMethod
|
||||
switch input.Body.Method {
|
||||
case "github":
|
||||
authMethod = model.AuthMethodGitHub
|
||||
case "none":
|
||||
authMethod = model.AuthMethodNone
|
||||
default:
|
||||
return nil, huma.Error400BadRequest("Invalid auth method: " + input.Body.Method)
|
||||
}
|
||||
|
||||
// Start the auth flow
|
||||
flowInfo, statusToken, err := authService.StartAuthFlow(ctx, authMethod, input.Body.RepoRef)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("Failed to start auth flow", err)
|
||||
}
|
||||
|
||||
return &Response[StartAuthBody]{
|
||||
Body: StartAuthBody{
|
||||
AuthFlowInfo: flowInfo,
|
||||
StatusToken: statusToken,
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
// Check auth status endpoint
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "check-auth-status",
|
||||
Method: http.MethodGet,
|
||||
Path: "/v0/auth/status/{token}",
|
||||
Summary: "Check authentication status",
|
||||
Description: "Check the status of an ongoing authentication flow",
|
||||
Tags: []string{"auth"},
|
||||
}, func(ctx context.Context, input *CheckAuthStatusInput) (*Response[CheckAuthStatusBody], error) {
|
||||
// Check the auth status
|
||||
status, err := authService.CheckAuthStatus(ctx, input.StatusToken)
|
||||
if err != nil {
|
||||
return nil, huma.Error404NotFound("Auth flow not found or expired")
|
||||
}
|
||||
|
||||
return &Response[CheckAuthStatusBody]{
|
||||
Body: CheckAuthStatusBody{
|
||||
Status: status,
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
204
internal/api/handlers/v0/auth/github.go
Normal file
204
internal/api/handlers/v0/auth/github.go
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
// GitHubTokenExchangeInput represents the input for GitHub token exchange
|
||||
type GitHubTokenExchangeInput struct {
|
||||
Body struct {
|
||||
GitHubToken string `json:"github_token" doc:"GitHub OAuth token" required:"true"`
|
||||
}
|
||||
}
|
||||
|
||||
// GitHubHandler handles GitHub authentication
|
||||
type GitHubHandler struct {
|
||||
config *config.Config
|
||||
jwtManager *auth.JWTManager
|
||||
baseURL string // Configurable for testing
|
||||
}
|
||||
|
||||
// NewGitHubHandler creates a new GitHub handler
|
||||
func NewGitHubHandler(cfg *config.Config) *GitHubHandler {
|
||||
return &GitHubHandler{
|
||||
config: cfg,
|
||||
jwtManager: auth.NewJWTManager(cfg),
|
||||
baseURL: "https://api.github.com",
|
||||
}
|
||||
}
|
||||
|
||||
// SetBaseURL sets the base URL for GitHub API (used for testing)
|
||||
func (h *GitHubHandler) SetBaseURL(url string) {
|
||||
h.baseURL = url
|
||||
}
|
||||
|
||||
// RegisterGitHubEndpoint registers the GitHub authentication endpoint
|
||||
func RegisterGitHubEndpoint(api huma.API, cfg *config.Config) {
|
||||
handler := NewGitHubHandler(cfg)
|
||||
|
||||
// GitHub token exchange endpoint
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "exchange-github-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/v0/auth/github",
|
||||
Summary: "Exchange GitHub token for Registry JWT",
|
||||
Description: "Exchange a GitHub OAuth token for a short-lived Registry JWT token",
|
||||
Tags: []string{"auth"},
|
||||
}, func(ctx context.Context, input *GitHubTokenExchangeInput) (*struct {
|
||||
Body auth.TokenResponse `json:"body"`
|
||||
}, error) {
|
||||
response, err := handler.ExchangeToken(ctx, input.Body.GitHubToken)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Token exchange failed", err)
|
||||
}
|
||||
|
||||
return &struct {
|
||||
Body auth.TokenResponse `json:"body"`
|
||||
}{
|
||||
Body: *response,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ExchangeToken exchanges a GitHub OAuth token for a Registry JWT token
|
||||
func (h *GitHubHandler) ExchangeToken(ctx context.Context, githubToken string) (*auth.TokenResponse, error) {
|
||||
// Get GitHub user information
|
||||
user, err := h.getGitHubUser(ctx, githubToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get GitHub user: %w", err)
|
||||
}
|
||||
|
||||
// Get user's organizations
|
||||
orgs, err := h.getGitHubUserOrgs(ctx, githubToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get GitHub organizations: %w", err)
|
||||
}
|
||||
|
||||
// Build permissions based on user and organizations
|
||||
permissions := h.buildPermissions(user.Login, orgs)
|
||||
|
||||
// Create JWT claims with GitHub user info
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethodSubject: user.Login,
|
||||
Permissions: permissions,
|
||||
}
|
||||
|
||||
// Generate Registry JWT token
|
||||
tokenResponse, err := h.jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate JWT token: %w", err)
|
||||
}
|
||||
|
||||
return tokenResponse, nil
|
||||
}
|
||||
|
||||
type GitHubUserOrOrg struct {
|
||||
Login string `json:"login"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
// getGitHubUser gets the authenticated user's information
|
||||
func (h *GitHubHandler) getGitHubUser(ctx context.Context, token string) (*GitHubUserOrOrg, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.baseURL+"/user", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user info: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var user GitHubUserOrOrg
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode user response: %w", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// getGitHubUserOrgs gets the authenticated user's organizations
|
||||
func (h *GitHubHandler) getGitHubUserOrgs(ctx context.Context, token string) ([]GitHubUserOrOrg, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.baseURL+"/user/orgs", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user organizations: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var orgs []GitHubUserOrOrg
|
||||
if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode organizations response: %w", err)
|
||||
}
|
||||
|
||||
return orgs, nil
|
||||
}
|
||||
|
||||
// buildPermissions builds permissions based on GitHub user and their organizations
|
||||
func (h *GitHubHandler) buildPermissions(username string, orgs []GitHubUserOrOrg) []auth.Permission {
|
||||
permissions := []auth.Permission{}
|
||||
|
||||
// Assert user and org names match expected regex, to harden against people doing weird things in names
|
||||
if !isValidGitHubName(username) {
|
||||
return nil
|
||||
}
|
||||
for _, org := range orgs {
|
||||
if !isValidGitHubName(org.Login) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Add permission for user's own namespace
|
||||
permissions = append(permissions, auth.Permission{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: fmt.Sprintf("io.github.%s/*", username),
|
||||
})
|
||||
|
||||
// Add permissions for each organization
|
||||
for _, org := range orgs {
|
||||
permissions = append(permissions, auth.Permission{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: fmt.Sprintf("io.github.%s/*", org.Login),
|
||||
})
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
|
||||
func isValidGitHubName(name string) bool {
|
||||
return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(name)
|
||||
}
|
||||
627
internal/api/handlers/v0/auth/github_test.go
Normal file
627
internal/api/handlers/v0/auth/github_test.go
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
v0auth "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
githubUserEndpoint = "/user"
|
||||
githubOrgsEndpoint = "/user/orgs"
|
||||
)
|
||||
|
||||
func TestGitHubHandler_ExchangeToken(t *testing.T) {
|
||||
// Create test handler with mock config
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
t.Run("successful token exchange with user only", func(t *testing.T) {
|
||||
// Create mock GitHub API server
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
assert.Equal(t, "Bearer valid-github-token", authHeader)
|
||||
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: "testuser",
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
orgs := []v0auth.GitHubUserOrOrg{}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-github-token")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
assert.NotEmpty(t, response.RegistryToken)
|
||||
assert.Greater(t, response.ExpiresAt, 0)
|
||||
|
||||
// Validate the JWT token
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.AuthMethodGitHub, claims.AuthMethod)
|
||||
assert.Equal(t, "testuser", claims.AuthMethodSubject)
|
||||
assert.Len(t, claims.Permissions, 1)
|
||||
assert.Equal(t, auth.PermissionActionPublish, claims.Permissions[0].Action)
|
||||
assert.Equal(t, "io.github.testuser/*", claims.Permissions[0].ResourcePattern)
|
||||
})
|
||||
|
||||
t.Run("successful token exchange with organizations", func(t *testing.T) {
|
||||
// Create mock GitHub API server
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: "testuser",
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
orgs := []v0auth.GitHubUserOrOrg{
|
||||
{Login: "test-org-1", ID: 1},
|
||||
{Login: "test-org-2", ID: 2},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-github-token")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
// Validate the JWT token
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "testuser", claims.AuthMethodSubject)
|
||||
assert.Len(t, claims.Permissions, 3) // User + 2 orgs
|
||||
|
||||
// Check permissions
|
||||
expectedPatterns := []string{
|
||||
"io.github.testuser/*",
|
||||
"io.github.test-org-1/*",
|
||||
"io.github.test-org-2/*",
|
||||
}
|
||||
for i, perm := range claims.Permissions {
|
||||
assert.Equal(t, auth.PermissionActionPublish, perm.Action)
|
||||
assert.Equal(t, expectedPatterns[i], perm.ResourcePattern)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid token returns error", func(t *testing.T) {
|
||||
// Create mock GitHub API server that returns 401
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"message": "Bad credentials"}`)) //nolint:errcheck
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "invalid-token")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "GitHub API error")
|
||||
assert.Contains(t, err.Error(), "401")
|
||||
})
|
||||
|
||||
t.Run("GitHub API error on user fetch", func(t *testing.T) {
|
||||
// Create mock GitHub API server that returns 500
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == githubUserEndpoint {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message": "Internal server error"}`)) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-token")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "failed to get GitHub user")
|
||||
})
|
||||
|
||||
t.Run("GitHub API error on orgs fetch", func(t *testing.T) {
|
||||
// Create mock GitHub API server
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: "testuser",
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message": "Internal server error"}`)) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-token")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "failed to get GitHub organizations")
|
||||
})
|
||||
|
||||
t.Run("invalid GitHub username returns empty permissions", func(t *testing.T) {
|
||||
// Create mock GitHub API server with invalid username
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: "user with spaces", // Invalid name
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
orgs := []v0auth.GitHubUserOrOrg{}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-token")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
// Validate the JWT token
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user with spaces", claims.AuthMethodSubject)
|
||||
assert.Empty(t, claims.Permissions) // No permissions due to invalid name
|
||||
})
|
||||
|
||||
t.Run("invalid org name is filtered out", func(t *testing.T) {
|
||||
// Create mock GitHub API server with invalid org name
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case githubUserEndpoint:
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: "validuser",
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
orgs := []v0auth.GitHubUserOrOrg{
|
||||
{Login: "valid-org", ID: 1},
|
||||
{Login: "org with spaces", ID: 2}, // Invalid name
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-token")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
// Validate the JWT token
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "validuser", claims.AuthMethodSubject)
|
||||
assert.Empty(t, claims.Permissions) // No permissions because one org has invalid name
|
||||
})
|
||||
|
||||
t.Run("malformed JSON response", func(t *testing.T) {
|
||||
// Create mock GitHub API server that returns invalid JSON
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == githubUserEndpoint {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{invalid json`)) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-token")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "failed to decode")
|
||||
})
|
||||
}
|
||||
|
||||
func TestJWTTokenValidation(t *testing.T) {
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("generate and validate token", func(t *testing.T) {
|
||||
// Create test claims
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: "io.github.testuser/*",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, tokenResponse.RegistryToken)
|
||||
|
||||
// Validate token
|
||||
validatedClaims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.AuthMethodGitHub, validatedClaims.AuthMethod)
|
||||
assert.Equal(t, "testuser", validatedClaims.AuthMethodSubject)
|
||||
assert.Len(t, validatedClaims.Permissions, 1)
|
||||
})
|
||||
|
||||
t.Run("token expiration", func(t *testing.T) {
|
||||
// Create claims with past expiration
|
||||
pastTime := time.Now().Add(-1 * time.Hour)
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethodSubject: "testuser",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(pastTime),
|
||||
IssuedAt: jwt.NewNumericDate(pastTime.Add(-1 * time.Hour)),
|
||||
},
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate token - should fail due to expiration
|
||||
_, err = jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "token is expired")
|
||||
})
|
||||
|
||||
t.Run("invalid signature", func(t *testing.T) {
|
||||
// Create test claims
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethodSubject: "testuser",
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Tamper with the token
|
||||
tamperedToken := tokenResponse.RegistryToken + "tampered"
|
||||
|
||||
// Validate token - should fail due to invalid signature
|
||||
_, err = jwtManager.ValidateToken(ctx, tamperedToken)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionResourceMatching(t *testing.T) {
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
resource string
|
||||
pattern string
|
||||
action auth.PermissionAction
|
||||
expectedMatch bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
resource: "io.github.testuser/myrepo",
|
||||
pattern: "io.github.testuser/myrepo",
|
||||
action: auth.PermissionActionPublish,
|
||||
expectedMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard match",
|
||||
resource: "io.github.testuser/myrepo",
|
||||
pattern: "io.github.testuser/*",
|
||||
action: auth.PermissionActionPublish,
|
||||
expectedMatch: true,
|
||||
},
|
||||
{
|
||||
name: "global wildcard",
|
||||
resource: "io.github.anyuser/anyrepo",
|
||||
pattern: "*",
|
||||
action: auth.PermissionActionPublish,
|
||||
expectedMatch: true,
|
||||
},
|
||||
{
|
||||
name: "no match different user",
|
||||
resource: "io.github.otheruser/repo",
|
||||
pattern: "io.github.testuser/*",
|
||||
action: auth.PermissionActionPublish,
|
||||
expectedMatch: false,
|
||||
},
|
||||
{
|
||||
name: "no match different action",
|
||||
resource: "io.github.testuser/repo",
|
||||
pattern: "io.github.testuser/*",
|
||||
action: auth.PermissionActionEdit,
|
||||
expectedMatch: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
permissions := []auth.Permission{
|
||||
{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: tc.pattern,
|
||||
},
|
||||
}
|
||||
|
||||
hasPermission := jwtManager.HasPermission(tc.resource, tc.action, permissions)
|
||||
assert.Equal(t, tc.expectedMatch, hasPermission)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidGitHubNames(t *testing.T) {
|
||||
// Create a minimal handler to test name validation
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
validNameTests := []struct {
|
||||
name string
|
||||
username string
|
||||
orgs []v0auth.GitHubUserOrOrg
|
||||
wantPerms int
|
||||
}{
|
||||
{
|
||||
name: "valid username only",
|
||||
username: "valid-user",
|
||||
orgs: []v0auth.GitHubUserOrOrg{},
|
||||
wantPerms: 1,
|
||||
},
|
||||
{
|
||||
name: "valid username with numbers",
|
||||
username: "user123",
|
||||
orgs: []v0auth.GitHubUserOrOrg{},
|
||||
wantPerms: 1,
|
||||
},
|
||||
{
|
||||
name: "valid username with org",
|
||||
username: "valid-user",
|
||||
orgs: []v0auth.GitHubUserOrOrg{
|
||||
{Login: "valid-org", ID: 1},
|
||||
},
|
||||
wantPerms: 2,
|
||||
},
|
||||
{
|
||||
name: "invalid username with spaces",
|
||||
username: "invalid user",
|
||||
orgs: []v0auth.GitHubUserOrOrg{},
|
||||
wantPerms: 0, // Should return nil/empty permissions
|
||||
},
|
||||
{
|
||||
name: "invalid username with special chars",
|
||||
username: "user@invalid",
|
||||
orgs: []v0auth.GitHubUserOrOrg{},
|
||||
wantPerms: 0,
|
||||
},
|
||||
{
|
||||
name: "valid username with invalid org",
|
||||
username: "valid-user",
|
||||
orgs: []v0auth.GitHubUserOrOrg{
|
||||
{Login: "invalid org", ID: 1},
|
||||
},
|
||||
wantPerms: 0, // Should return nil if any name is invalid
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range validNameTests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create mock server
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/user":
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: tc.username,
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case githubOrgsEndpoint:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tc.orgs) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create handler and set mock server URL
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Test token exchange
|
||||
ctx := context.Background()
|
||||
response, err := handler.ExchangeToken(ctx, "valid-token")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate the JWT token and check permissions
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, response.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, claims.Permissions, tc.wantPerms)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubHandler_Creation(t *testing.T) {
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
assert.NotNil(t, handler, "handler should not be nil")
|
||||
}
|
||||
|
||||
func TestConcurrentTokenExchange(t *testing.T) {
|
||||
// Test that the handler is thread-safe
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
// Create mock GitHub API server
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/user":
|
||||
user := v0auth.GitHubUserOrOrg{
|
||||
Login: "testuser",
|
||||
ID: 12345,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user) //nolint:errcheck
|
||||
case "/user/orgs":
|
||||
orgs := []v0auth.GitHubUserOrOrg{}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(orgs) //nolint:errcheck
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
handler := v0auth.NewGitHubHandler(cfg)
|
||||
handler.SetBaseURL(mockServer.URL)
|
||||
|
||||
// Run multiple concurrent exchanges
|
||||
concurrency := 10
|
||||
errors := make(chan error, concurrency)
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
_, err := handler.ExchangeToken(ctx, fmt.Sprintf("token-%d", i))
|
||||
errors <- err
|
||||
}()
|
||||
}
|
||||
|
||||
// Collect results
|
||||
for i := 0; i < concurrency; i++ {
|
||||
err := <-errors
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}
|
||||
19
internal/api/handlers/v0/auth/main.go
Normal file
19
internal/api/handlers/v0/auth/main.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
)
|
||||
|
||||
// RegisterAuthEndpoints registers all authentication endpoints
|
||||
func RegisterAuthEndpoints(api huma.API, cfg *config.Config) {
|
||||
// Register GitHub authentication endpoint
|
||||
RegisterGitHubEndpoint(api, cfg)
|
||||
|
||||
// Register anonymous authentication endpoint
|
||||
RegisterNoneEndpoint(api, cfg)
|
||||
|
||||
// Future auth providers can be registered here:
|
||||
// RegisterGitLabEndpoint(api, cfg)
|
||||
// RegisterOIDCEndpoint(api, cfg)
|
||||
}
|
||||
84
internal/api/handlers/v0/auth/none.go
Normal file
84
internal/api/handlers/v0/auth/none.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
)
|
||||
|
||||
// NoneHandler handles anonymous authentication
|
||||
type NoneHandler struct {
|
||||
config *config.Config
|
||||
jwtManager *auth.JWTManager
|
||||
}
|
||||
|
||||
// NewNoneHandler creates a new anonymous authentication handler
|
||||
func NewNoneHandler(cfg *config.Config) *NoneHandler {
|
||||
return &NoneHandler{
|
||||
config: cfg,
|
||||
jwtManager: auth.NewJWTManager(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterNoneEndpoint registers the anonymous authentication endpoint
|
||||
func RegisterNoneEndpoint(api huma.API, cfg *config.Config) {
|
||||
if !cfg.EnableAnonymousAuth {
|
||||
return
|
||||
}
|
||||
|
||||
handler := NewNoneHandler(cfg)
|
||||
|
||||
// Anonymous token endpoint
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "get-anonymous-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/v0/auth/none",
|
||||
Summary: "Get anonymous Registry JWT",
|
||||
Description: "Get a short-lived Registry JWT token for publishing to io.modelcontextprotocol.anonymous/* namespace",
|
||||
Tags: []string{"auth"},
|
||||
}, func(ctx context.Context, _ *struct{}) (*struct {
|
||||
Body auth.TokenResponse `json:"body"`
|
||||
}, error) {
|
||||
response, err := handler.GetAnonymousToken(ctx)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("Failed to generate token", err)
|
||||
}
|
||||
|
||||
return &struct {
|
||||
Body auth.TokenResponse `json:"body"`
|
||||
}{
|
||||
Body: *response,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetAnonymousToken generates an anonymous Registry JWT token
|
||||
func (h *NoneHandler) GetAnonymousToken(ctx context.Context) (*auth.TokenResponse, error) {
|
||||
// Build permissions for anonymous namespace only
|
||||
permissions := []auth.Permission{
|
||||
{
|
||||
Action: auth.PermissionActionPublish,
|
||||
ResourcePattern: "io.modelcontextprotocol.anonymous/*",
|
||||
},
|
||||
}
|
||||
|
||||
// Create JWT claims for anonymous user
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodNone,
|
||||
AuthMethodSubject: "anonymous",
|
||||
Permissions: permissions,
|
||||
}
|
||||
|
||||
// Generate Registry JWT token
|
||||
tokenResponse, err := h.jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate JWT token: %w", err)
|
||||
}
|
||||
|
||||
return tokenResponse, nil
|
||||
}
|
||||
51
internal/api/handlers/v0/auth/none_test.go
Normal file
51
internal/api/handlers/v0/auth/none_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
v0auth "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNoneHandler_GetAnonymousToken(t *testing.T) {
|
||||
// Generate a proper Ed25519 seed for testing
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
EnableAnonymousAuth: true,
|
||||
}
|
||||
|
||||
handler := v0auth.NewNoneHandler(cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
// Test getting anonymous token
|
||||
tokenResponse, err := handler.GetAnonymousToken(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, tokenResponse.RegistryToken)
|
||||
assert.Greater(t, tokenResponse.ExpiresAt, 0)
|
||||
|
||||
// Validate the token claims
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
claims, err := jwtManager.ValidateToken(ctx, tokenResponse.RegistryToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check auth method
|
||||
assert.Equal(t, model.AuthMethodNone, claims.AuthMethod)
|
||||
assert.Equal(t, "anonymous", claims.AuthMethodSubject)
|
||||
|
||||
// Check permissions
|
||||
require.Len(t, claims.Permissions, 1)
|
||||
assert.Equal(t, auth.PermissionActionPublish, claims.Permissions[0].Action)
|
||||
assert.Equal(t, "io.modelcontextprotocol.anonymous/*", claims.Permissions[0].ResourcePattern)
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ func TestHealthEndpoint(t *testing.T) {
|
|||
// Create a new test API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
|
||||
// Register the health endpoint
|
||||
v0.RegisterHealthEndpoint(api, tc.config)
|
||||
|
||||
|
|
@ -67,7 +67,7 @@ func TestHealthEndpoint(t *testing.T) {
|
|||
// Since Huma adds a $schema field, we'll check individual fields
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, `"status":"ok"`)
|
||||
|
||||
|
||||
if tc.config.GithubClientID != "" {
|
||||
assert.Contains(t, body, `"github_client_id":"test-github-client-id"`)
|
||||
} else {
|
||||
|
|
@ -75,4 +75,4 @@ func TestHealthEndpoint(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ func TestPingEndpoint(t *testing.T) {
|
|||
// Create a new test API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
|
||||
// Register the ping endpoint
|
||||
v0.RegisterPingEndpoint(api)
|
||||
|
||||
|
|
@ -32,4 +32,4 @@ func TestPingEndpoint(t *testing.T) {
|
|||
// Check the response body contains pong
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, `"pong":true`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,19 +7,22 @@ import (
|
|||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/modelcontextprotocol/registry/internal/auth"
|
||||
"github.com/modelcontextprotocol/registry/internal/config"
|
||||
"github.com/modelcontextprotocol/registry/internal/model"
|
||||
"github.com/modelcontextprotocol/registry/internal/service"
|
||||
)
|
||||
|
||||
// PublishServerInput represents the input for publishing a server
|
||||
type PublishServerInput struct {
|
||||
Authorization string `header:"Authorization" doc:"GitHub OAuth token" required:"true"`
|
||||
Authorization string `header:"Authorization" doc:"Registry JWT token (obtained from /v0/auth/token/github)" required:"true"`
|
||||
Body model.PublishRequest
|
||||
}
|
||||
|
||||
|
||||
// RegisterPublishEndpoint registers the publish endpoint
|
||||
func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, authService auth.Service) {
|
||||
func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg *config.Config) {
|
||||
// Create JWT manager for token validation
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "publish-server",
|
||||
Method: http.MethodPost,
|
||||
|
|
@ -39,40 +42,18 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, aut
|
|||
}
|
||||
token := authHeader[len(bearerPrefix):]
|
||||
|
||||
// Validate Registry JWT token
|
||||
claims, err := jwtManager.ValidateToken(ctx, token)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Invalid or expired Registry JWT token", err)
|
||||
}
|
||||
|
||||
// Convert PublishRequest body to ServerDetail
|
||||
serverDetail := input.Body.ServerDetail
|
||||
|
||||
// Huma handles validation automatically based on struct tags
|
||||
// But we can add custom validation if needed
|
||||
if serverDetail.Name == "" {
|
||||
return nil, huma.Error400BadRequest("Name is required")
|
||||
}
|
||||
if serverDetail.VersionDetail.Version == "" {
|
||||
return nil, huma.Error400BadRequest("Version is required")
|
||||
}
|
||||
|
||||
// Determine authentication method based on server name prefix
|
||||
var authMethod model.AuthMethod
|
||||
if strings.HasPrefix(serverDetail.Name, "io.github") {
|
||||
authMethod = model.AuthMethodGitHub
|
||||
} else {
|
||||
authMethod = model.AuthMethodNone
|
||||
}
|
||||
|
||||
// Setup authentication info
|
||||
a := model.Authentication{
|
||||
Method: authMethod,
|
||||
Token: token,
|
||||
RepoRef: serverDetail.Name,
|
||||
}
|
||||
|
||||
// Validate authentication
|
||||
valid, err := authService.ValidateAuth(ctx, a)
|
||||
if err != nil {
|
||||
return nil, huma.Error401Unauthorized("Authentication failed", err)
|
||||
}
|
||||
if !valid {
|
||||
return nil, huma.Error401Unauthorized("Invalid authentication credentials")
|
||||
// Verify that the token's repository matches the server being published
|
||||
if !jwtManager.HasPermission(serverDetail.Name, auth.PermissionActionPublish, claims.Permissions) {
|
||||
return nil, huma.Error403Forbidden("You do not have permission to publish this server")
|
||||
}
|
||||
|
||||
// Publish the server details
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ package v0_test
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
|
@ -10,25 +14,43 @@ import (
|
|||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||
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/model"
|
||||
"github.com/modelcontextprotocol/registry/internal/service"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Helper function to generate a valid JWT token for testing
|
||||
func generateIntegrationTestJWTToken(cfg *config.Config, claims auth.JWTClaims) (string, error) {
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
ctx := context.Background()
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tokenResponse.RegistryToken, nil
|
||||
}
|
||||
|
||||
func TestPublishIntegration(t *testing.T) {
|
||||
// Setup fake service and auth service
|
||||
// Setup fake service
|
||||
registryService := service.NewFakeRegistryService()
|
||||
authService := &MockAuthService{}
|
||||
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
|
||||
|
||||
// Create test config with a valid Ed25519 seed
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
testConfig := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
// Create a new ServeMux and Huma API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
|
||||
// Register the endpoint
|
||||
v0.RegisterPublishEndpoint(api, registryService, authService)
|
||||
v0.RegisterPublishEndpoint(api, registryService, testConfig)
|
||||
|
||||
t.Run("successful publish with GitHub auth", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
|
|
@ -45,115 +67,52 @@ func TestPublishIntegration(t *testing.T) {
|
|||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryName: "npm",
|
||||
Name: "test-mcp-server",
|
||||
Version: "1.0.0",
|
||||
RunTimeHint: "node",
|
||||
RuntimeArguments: []model.Argument{
|
||||
{
|
||||
Type: model.ArgumentTypeNamed,
|
||||
Name: "config",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "Configuration file path",
|
||||
Format: model.FormatFilePath,
|
||||
IsRequired: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Remotes: []model.Remote{
|
||||
{
|
||||
TransportType: "http",
|
||||
URL: "http://localhost:3000/mcp",
|
||||
},
|
||||
},
|
||||
// Packages field removed as it depends on the actual model.Package structure
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal the server detail to JSON
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
// Generate valid JWT token
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethodSubject: "testuser",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.testuser/*"},
|
||||
},
|
||||
}
|
||||
token, err := generateIntegrationTestJWTToken(testConfig, claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a request
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
body, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer github_test_token_123")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
// Create a response recorder
|
||||
recorder := httptest.NewRecorder()
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
// Call the handler through the mux
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
// Check the response
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
err = json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, response.ID, "Server ID should be generated")
|
||||
assert.Equal(t, publishReq.Name, response.Name)
|
||||
assert.Equal(t, publishReq.Description, response.Description)
|
||||
|
||||
// Verify the server was actually published by retrieving it
|
||||
publishedServer, err := registryService.GetByID(response.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, publishReq.Name, publishedServer.Name)
|
||||
assert.Equal(t, publishReq.Description, publishedServer.Description)
|
||||
assert.Equal(t, publishReq.VersionDetail.Version, publishedServer.VersionDetail.Version)
|
||||
assert.Len(t, publishedServer.Packages, 1)
|
||||
assert.Len(t, publishedServer.Remotes, 1)
|
||||
assert.Equal(t, publishReq.VersionDetail.Version, response.VersionDetail.Version)
|
||||
})
|
||||
|
||||
t.Run("successful publish without auth (no prefix)", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "custom-mcp-server",
|
||||
Description: "A custom MCP server without auth",
|
||||
Name: "test-mcp-server-no-auth",
|
||||
Description: "A test MCP server without authentication",
|
||||
Repository: model.Repository{
|
||||
URL: "https://example.com/custom-server",
|
||||
Source: "custom",
|
||||
ID: "custom/custom-server",
|
||||
URL: "https://example.com/test-mcp-server",
|
||||
Source: "example",
|
||||
ID: "test-mcp-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "2.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer dummy_token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, response.ID, "Server ID should be generated")
|
||||
})
|
||||
|
||||
t.Run("publish fails with missing name", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "", // Missing name
|
||||
Description: "A test server",
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
|
|
@ -161,52 +120,86 @@ func TestPublishIntegration(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "Name is required")
|
||||
})
|
||||
|
||||
t.Run("publish fails with missing version", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "test-server",
|
||||
Description: "A test server",
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "", // Missing version
|
||||
},
|
||||
},
|
||||
// Generate valid JWT token with wildcard permission
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodNone,
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
token, err := generateIntegrationTestJWTToken(testConfig, claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
body, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "Version is required")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, publishReq.Name, response.Name)
|
||||
})
|
||||
|
||||
t.Run("publish fails with missing authorization header", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "test-server",
|
||||
Name: "test-server",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// No Authorization header
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rr.Code)
|
||||
assert.Contains(t, rr.Body.String(), "required header parameter is missing")
|
||||
})
|
||||
|
||||
t.Run("publish fails with invalid token", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "test-server",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
assert.Contains(t, rr.Body.String(), "Invalid or expired Registry JWT token")
|
||||
})
|
||||
|
||||
t.Run("publish fails when permission denied", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.other/test-server",
|
||||
Description: "A test server",
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
|
|
@ -215,517 +208,27 @@ func TestPublishIntegration(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
// Generate valid JWT token but with different permissions
|
||||
claims := auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.myuser/*"},
|
||||
},
|
||||
}
|
||||
token, err := generateIntegrationTestJWTToken(testConfig, claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
body, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// No Authorization header
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "required header parameter is missing")
|
||||
})
|
||||
|
||||
t.Run("publish fails with invalid JSON", func(t *testing.T) {
|
||||
invalidJSON := `{"name": "test", "version": `
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBufferString(invalidJSON))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "unexpected end of JSON")
|
||||
})
|
||||
|
||||
t.Run("publish fails with unsupported HTTP method", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v0/publish", nil)
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "Method Not Allowed")
|
||||
})
|
||||
|
||||
t.Run("publish fails with duplicate name and version", func(t *testing.T) {
|
||||
// First, publish a server successfully
|
||||
firstServerDetail := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.duplicate/test-server",
|
||||
Description: "First server for duplicate test",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/duplicate/test-server",
|
||||
Source: "github",
|
||||
ID: "duplicate/test-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(firstServerDetail)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer github_token_first")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code, "First publish should succeed")
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstServerID := response.ID // Store the ID for later verification
|
||||
|
||||
// Now try to publish another server with the same name and version
|
||||
duplicateServerDetail := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.duplicate/test-server", // Same name
|
||||
Description: "Duplicate server attempt",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/duplicate/test-server-fork",
|
||||
Source: "github",
|
||||
ID: "duplicate/test-server-fork",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0", // Same version
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
duplicateJSONData, err := json.Marshal(duplicateServerDetail)
|
||||
require.NoError(t, err)
|
||||
|
||||
duplicateReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(duplicateJSONData))
|
||||
duplicateReq.Header.Set("Content-Type", "application/json")
|
||||
duplicateReq.Header.Set("Authorization", "Bearer github_token_duplicate")
|
||||
|
||||
duplicateRecorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(duplicateRecorder, duplicateReq)
|
||||
|
||||
// The duplicate should fail
|
||||
assert.Equal(t, http.StatusInternalServerError, duplicateRecorder.Code)
|
||||
assert.Contains(t, duplicateRecorder.Body.String(), "Failed to publish server")
|
||||
|
||||
// Verify that only the first server was actually stored
|
||||
retrievedServer, err := registryService.GetByID(firstServerID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, firstServerDetail.Name, retrievedServer.Name)
|
||||
assert.Equal(t, firstServerDetail.Description, retrievedServer.Description)
|
||||
})
|
||||
|
||||
t.Run("publish succeeds with same name but different version", func(t *testing.T) {
|
||||
// Publish first version
|
||||
firstVersionDetail := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.versioned/test-server",
|
||||
Description: "First version of the server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/versioned/test-server",
|
||||
Source: "github",
|
||||
ID: "versioned/test-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(firstVersionDetail)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer github_token_v1")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code, "First version should succeed")
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
firstVersionID := response.ID // Store the ID for later verification
|
||||
require.NotEmpty(t, firstVersionID, "Server ID should be generated")
|
||||
|
||||
// Publish second version with same name but different version
|
||||
secondVersionDetail := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.versioned/test-server", // Same name
|
||||
Description: "Second version of the server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/versioned/test-server",
|
||||
Source: "github",
|
||||
ID: "versioned/test-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "2.0.0", // Different version
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
secondJSONData, err := json.Marshal(secondVersionDetail)
|
||||
require.NoError(t, err)
|
||||
|
||||
secondReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(secondJSONData))
|
||||
secondReq.Header.Set("Content-Type", "application/json")
|
||||
secondReq.Header.Set("Authorization", "Bearer github_token_v2")
|
||||
|
||||
secondRecorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(secondRecorder, secondReq)
|
||||
|
||||
// The second version should succeed
|
||||
assert.Equal(t, http.StatusOK, secondRecorder.Code)
|
||||
|
||||
var secondResponse model.Server
|
||||
err = json.Unmarshal(secondRecorder.Body.Bytes(), &secondResponse)
|
||||
require.NoError(t, err)
|
||||
secondVersionID := secondResponse.ID // Store the ID for later verification
|
||||
require.NotEmpty(t, secondVersionID, "Server ID for second version should be generated")
|
||||
|
||||
// Verify both versions exist
|
||||
firstRetrieved, err := registryService.GetByID(firstVersionID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1.0.0", firstRetrieved.VersionDetail.Version)
|
||||
|
||||
secondRetrieved, err := registryService.GetByID(secondVersionID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2.0.0", secondRetrieved.VersionDetail.Version)
|
||||
})
|
||||
|
||||
t.Run("publish fails when trying to publish older version after newer version", func(t *testing.T) {
|
||||
// First, publish a newer version (2.0.0)
|
||||
newerVersionDetail := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.versioning/version-order-test",
|
||||
Description: "Newer version published first",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/versioning/version-order-test",
|
||||
Source: "github",
|
||||
ID: "versioning/version-order-test",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "2.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(newerVersionDetail)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer github_token_newer")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code, "Newer version should be published successfully")
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
newerVersionID := response.ID // Store the ID for later verification
|
||||
require.NotEmpty(t, newerVersionID, "Server ID for newer version should be generated")
|
||||
|
||||
// Now try to publish an older version (1.0.0) of the same package
|
||||
olderVersionDetail := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.versioning/version-order-test", // Same name
|
||||
Description: "Older version published after newer",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/versioning/version-order-test",
|
||||
Source: "github",
|
||||
ID: "versioning/version-order-test",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0", // Older version
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
olderJSONData, err := json.Marshal(olderVersionDetail)
|
||||
require.NoError(t, err)
|
||||
|
||||
olderReq := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(olderJSONData))
|
||||
olderReq.Header.Set("Content-Type", "application/json")
|
||||
olderReq.Header.Set("Authorization", "Bearer github_token_older")
|
||||
|
||||
olderRecorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(olderRecorder, olderReq)
|
||||
|
||||
// This should fail - we shouldn't allow publishing older versions after newer ones
|
||||
assert.Equal(t, http.StatusInternalServerError, olderRecorder.Code, "Publishing older version should fail")
|
||||
assert.Contains(t, olderRecorder.Body.String(), "Failed to publish server", "Error message should mention version")
|
||||
|
||||
// Verify that only the newer version exists
|
||||
newerRetrieved, err := registryService.GetByID(newerVersionID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2.0.0", newerRetrieved.VersionDetail.Version)
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
assert.Contains(t, rr.Body.String(), "You do not have permission to publish this server")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPublishIntegrationEndToEnd(t *testing.T) {
|
||||
registryService := service.NewFakeRegistryService()
|
||||
authService := &MockAuthService{}
|
||||
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
|
||||
|
||||
// Create a new ServeMux and Huma API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
// Register the endpoint
|
||||
v0.RegisterPublishEndpoint(api, registryService, authService)
|
||||
|
||||
t.Run("end-to-end publish and retrieve flow", func(t *testing.T) {
|
||||
// Step 1: Get initial count of servers
|
||||
initialServers, _, err := registryService.List("", 100)
|
||||
require.NoError(t, err)
|
||||
initialCount := len(initialServers)
|
||||
|
||||
// Step 2: Publish a new server
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.e2e/end-to-end-server",
|
||||
Description: "End-to-end test server",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/e2e/end-to-end-server",
|
||||
Source: "github",
|
||||
ID: "e2e/end-to-end-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer github_e2e_token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
serverID := response.ID
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
// Step 3: Verify the count increased
|
||||
updatedServers, _, err := registryService.List("", 100)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, initialCount+1, len(updatedServers))
|
||||
|
||||
// Step 4: Verify the server can be retrieved by ID
|
||||
retrievedServer, err := registryService.GetByID(serverID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, publishReq.Name, retrievedServer.Name)
|
||||
assert.Equal(t, publishReq.Description, retrievedServer.Description)
|
||||
|
||||
// Step 5: Verify the server appears in the list
|
||||
found := false
|
||||
for _, server := range updatedServers {
|
||||
if server.ID == serverID {
|
||||
found = true
|
||||
assert.Equal(t, publishReq.Name, server.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Published server should appear in the list")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPublishIntegrationWithComplexPackages(t *testing.T) {
|
||||
registryService := service.NewFakeRegistryService()
|
||||
authService := &MockAuthService{}
|
||||
authService.Mock.On("ValidateAuth", mock.Anything, mock.AnythingOfType("model.Authentication")).Return(true, nil)
|
||||
|
||||
// Create a new ServeMux and Huma API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
// Register the endpoint
|
||||
v0.RegisterPublishEndpoint(api, registryService, authService)
|
||||
|
||||
t.Run("publish with complex package configuration", func(t *testing.T) {
|
||||
publishReq := model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
Name: "io.github.complex/advanced-mcp-server",
|
||||
Description: "An advanced MCP server with complex configuration",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/complex/advanced-mcp-server",
|
||||
Source: "github",
|
||||
ID: "complex/advanced-mcp-server",
|
||||
},
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "2.1.0",
|
||||
},
|
||||
},
|
||||
Packages: []model.Package{
|
||||
{
|
||||
RegistryName: "npm",
|
||||
Name: "@example/advanced-mcp-server",
|
||||
Version: "43.1.0",
|
||||
RunTimeHint: "node",
|
||||
RuntimeArguments: []model.Argument{
|
||||
{
|
||||
Type: model.ArgumentTypeNamed,
|
||||
Name: "experimental-modules",
|
||||
},
|
||||
{
|
||||
Type: model.ArgumentTypeNamed,
|
||||
Name: "config",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "Main configuration file",
|
||||
Format: model.FormatFilePath,
|
||||
IsRequired: true,
|
||||
Default: "./config.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: model.ArgumentTypePositional,
|
||||
Name: "mode",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "Operation mode",
|
||||
Format: model.FormatString,
|
||||
IsRequired: false,
|
||||
Default: "production",
|
||||
Choices: []string{"development", "staging", "production"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
PackageArguments: []model.Argument{
|
||||
{
|
||||
Type: model.ArgumentTypeNamed,
|
||||
Name: "install-deps",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "Install dependencies",
|
||||
Format: model.FormatBoolean,
|
||||
Default: "true",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
EnvironmentVariables: []model.KeyValueInput{
|
||||
{
|
||||
Name: "LOG_LEVEL",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "Logging level",
|
||||
Format: model.FormatString,
|
||||
Default: "info",
|
||||
Choices: []string{"debug", "info", "warn", "error"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "API_KEY",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "API key for external service",
|
||||
Format: model.FormatString,
|
||||
IsRequired: true,
|
||||
IsSecret: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Remotes: []model.Remote{
|
||||
{
|
||||
TransportType: "http",
|
||||
URL: "http://localhost:8080/mcp",
|
||||
Headers: []model.KeyValueInput{
|
||||
{
|
||||
Name: "API-Version",
|
||||
InputWithVariables: model.InputWithVariables{
|
||||
Input: model.Input{
|
||||
Description: "API Version Header",
|
||||
Format: model.FormatString,
|
||||
Value: "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(publishReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer github_complex_token")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
var response model.Server
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
serverID := response.ID
|
||||
assert.NotEmpty(t, serverID, "Server ID should be generated")
|
||||
|
||||
// Verify the complex server was published correctly
|
||||
publishedServer, err := registryService.GetByID(serverID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify package details
|
||||
require.Len(t, publishedServer.Packages, 1)
|
||||
pkg := publishedServer.Packages[0]
|
||||
assert.Equal(t, "npm", pkg.RegistryName)
|
||||
assert.Equal(t, "@example/advanced-mcp-server", pkg.Name)
|
||||
assert.Len(t, pkg.RuntimeArguments, 3)
|
||||
assert.Len(t, pkg.PackageArguments, 1)
|
||||
assert.Len(t, pkg.EnvironmentVariables, 2)
|
||||
|
||||
// Verify remotes
|
||||
require.Len(t, publishedServer.Remotes, 1)
|
||||
assert.Equal(t, "http", publishedServer.Remotes[0].TransportType)
|
||||
assert.Len(t, publishedServer.Remotes[0].Headers, 1)
|
||||
})
|
||||
}
|
||||
|
|
@ -3,7 +3,11 @@ package v0_test
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
|
@ -12,9 +16,11 @@ import (
|
|||
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||
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/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockRegistryService is a mock implementation of the RegistryService interface
|
||||
|
|
@ -40,35 +46,33 @@ func (m *MockRegistryService) Publish(serverDetail *model.ServerDetail) error {
|
|||
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)
|
||||
// Helper function to generate a valid JWT token for testing
|
||||
func generateTestJWTToken(cfg *config.Config, claims auth.JWTClaims) (string, error) {
|
||||
jwtManager := auth.NewJWTManager(cfg)
|
||||
ctx := context.Background()
|
||||
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tokenResponse.RegistryToken, nil
|
||||
}
|
||||
|
||||
func TestPublishEndpoint(t *testing.T) {
|
||||
testSeed := make([]byte, ed25519.SeedSize)
|
||||
_, err := rand.Read(testSeed)
|
||||
require.NoError(t, err)
|
||||
testConfig := &config.Config{
|
||||
JWTPrivateKey: hex.EncodeToString(testSeed),
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestBody interface{}
|
||||
authHeader string
|
||||
setupMocks func(*MockRegistryService, *MockAuthService)
|
||||
expectedStatus int
|
||||
expectedResponse string
|
||||
expectedError string
|
||||
name string
|
||||
requestBody interface{}
|
||||
tokenClaims *auth.JWTClaims
|
||||
authHeader string
|
||||
setupMocks func(*MockRegistryService)
|
||||
expectedStatus int
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "successful publish with GitHub auth",
|
||||
|
|
@ -91,13 +95,14 @@ func TestPublishEndpoint(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
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)
|
||||
tokenClaims: &auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
AuthMethodSubject: "example",
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
|
||||
},
|
||||
},
|
||||
setupMocks: func(registry *MockRegistryService) {
|
||||
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil)
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
|
|
@ -123,13 +128,13 @@ func TestPublishEndpoint(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
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)
|
||||
tokenClaims: &auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodNone,
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "example/*"},
|
||||
},
|
||||
},
|
||||
setupMocks: func(registry *MockRegistryService) {
|
||||
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil)
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
|
|
@ -137,8 +142,8 @@ func TestPublishEndpoint(t *testing.T) {
|
|||
{
|
||||
name: "missing authorization header",
|
||||
requestBody: model.PublishRequest{},
|
||||
authHeader: "",
|
||||
setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {},
|
||||
authHeader: "", // Empty auth header
|
||||
setupMocks: func(_ *MockRegistryService) {},
|
||||
expectedStatus: http.StatusUnprocessableEntity,
|
||||
expectedError: "required header parameter is missing",
|
||||
},
|
||||
|
|
@ -146,212 +151,92 @@ func TestPublishEndpoint(t *testing.T) {
|
|||
name: "invalid authorization header format",
|
||||
requestBody: model.PublishRequest{},
|
||||
authHeader: "InvalidFormat",
|
||||
setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {},
|
||||
setupMocks: func(_ *MockRegistryService) {},
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
expectedError: "Invalid Authorization header format",
|
||||
},
|
||||
{
|
||||
name: "missing server name",
|
||||
name: "invalid token",
|
||||
requestBody: model.PublishRequest{
|
||||
ServerDetail: 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: "Bearer token",
|
||||
setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedError: "Name is required",
|
||||
},
|
||||
{
|
||||
name: "missing version",
|
||||
requestBody: model.PublishRequest{
|
||||
ServerDetail: 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,
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
authHeader: "Bearer token",
|
||||
setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedError: "Version is required",
|
||||
},
|
||||
{
|
||||
name: "authentication required error",
|
||||
requestBody: model.PublishRequest{
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
authHeader: "Bearer token",
|
||||
setupMocks: func(_ *MockRegistryService, authSvc *MockAuthService) {
|
||||
authSvc.On("ValidateAuth", mock.Anything, mock.Anything).Return(false, auth.ErrAuthRequired)
|
||||
},
|
||||
authHeader: "Bearer invalidToken",
|
||||
setupMocks: func(_ *MockRegistryService) {},
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
expectedError: "Authentication failed",
|
||||
expectedError: "Invalid or expired Registry JWT token",
|
||||
},
|
||||
{
|
||||
name: "authentication failed",
|
||||
name: "permission denied",
|
||||
requestBody: model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
ID: "test-id",
|
||||
Name: "test-server",
|
||||
Name: "io.github.other/test-server",
|
||||
Description: "A test server",
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
ReleaseDate: "2025-05-25T00:00:00Z",
|
||||
IsLatest: true,
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
authHeader: "Bearer invalid_token",
|
||||
setupMocks: func(_ *MockRegistryService, authSvc *MockAuthService) {
|
||||
authSvc.On("ValidateAuth", mock.Anything, mock.Anything).Return(false, nil)
|
||||
tokenClaims: &auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodGitHub,
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
|
||||
},
|
||||
},
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
expectedError: "Invalid authentication credentials",
|
||||
setupMocks: func(_ *MockRegistryService) {},
|
||||
expectedStatus: http.StatusForbidden,
|
||||
expectedError: "You do not have permission to publish this server",
|
||||
},
|
||||
{
|
||||
name: "registry service error",
|
||||
requestBody: model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
ID: "test-id",
|
||||
Name: "test-server",
|
||||
Name: "example/test-server",
|
||||
Description: "A test server",
|
||||
VersionDetail: model.VersionDetail{
|
||||
Version: "1.0.0",
|
||||
ReleaseDate: "2025-05-25T00:00:00Z",
|
||||
IsLatest: true,
|
||||
Version: "1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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)
|
||||
tokenClaims: &auth.JWTClaims{
|
||||
AuthMethod: model.AuthMethodNone,
|
||||
Permissions: []auth.Permission{
|
||||
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
|
||||
},
|
||||
},
|
||||
setupMocks: func(registry *MockRegistryService) {
|
||||
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(errors.New("database error"))
|
||||
},
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
expectedError: "Failed to publish server",
|
||||
},
|
||||
{
|
||||
name: "method not allowed",
|
||||
requestBody: nil,
|
||||
authHeader: "",
|
||||
setupMocks: func(_ *MockRegistryService, _ *MockAuthService) {},
|
||||
expectedStatus: http.StatusMethodNotAllowed,
|
||||
expectedError: "Method Not Allowed",
|
||||
},
|
||||
{
|
||||
name: "HTML injection attack in name field",
|
||||
requestBody: model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
ID: "test-id-html",
|
||||
Name: "io.github.malicious/<script>alert('XSS')</script>test-server",
|
||||
Description: "A test server with HTML injection attempt",
|
||||
Repository: model.Repository{
|
||||
URL: "https://github.com/malicious/test-server",
|
||||
Source: "github",
|
||||
ID: "malicious/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) {
|
||||
// The implementation should escape HTML
|
||||
authSvc.On("ValidateAuth", mock.Anything, mock.MatchedBy(func(auth model.Authentication) bool {
|
||||
return auth.Method == model.AuthMethodGitHub &&
|
||||
auth.Token == "github_token_123" &&
|
||||
auth.RepoRef == "io.github.malicious/<script>alert('XSS')</script>test-server"
|
||||
})).Return(true, nil)
|
||||
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil)
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "HTML injection attack in name field with non-GitHub prefix",
|
||||
requestBody: model.PublishRequest{
|
||||
ServerDetail: model.ServerDetail{
|
||||
Server: model.Server{
|
||||
ID: "test-id-html-non-github",
|
||||
Name: "malicious.com/<script>alert('XSS')</script>test-server",
|
||||
Description: "A test server with HTML injection attempt (non-GitHub)",
|
||||
Repository: model.Repository{
|
||||
URL: "https://malicious.com/test-server",
|
||||
Source: "custom",
|
||||
ID: "malicious/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, mock.MatchedBy(func(auth model.Authentication) bool {
|
||||
return auth.Method == model.AuthMethodNone &&
|
||||
auth.Token == "some_token" &&
|
||||
auth.RepoRef == "malicious.com/<script>alert('XSS')</script>test-server"
|
||||
})).Return(true, nil)
|
||||
registry.On("Publish", mock.AnythingOfType("*model.ServerDetail")).Return(nil)
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
tc.setupMocks(mockRegistry)
|
||||
|
||||
// Create a new ServeMux and Huma API
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
|
||||
// Register the endpoint
|
||||
v0.RegisterPublishEndpoint(api, mockRegistry, mockAuthService)
|
||||
// Register the endpoint with test config
|
||||
v0.RegisterPublishEndpoint(api, mockRegistry, testConfig)
|
||||
|
||||
// Prepare request body
|
||||
var requestBody []byte
|
||||
|
|
@ -362,178 +247,33 @@ func TestPublishEndpoint(t *testing.T) {
|
|||
}
|
||||
|
||||
// Create request
|
||||
method := http.MethodPost
|
||||
if tc.name == "method not allowed" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
req, err := http.NewRequestWithContext(context.Background(), method, "/v0/publish", bytes.NewBuffer(requestBody))
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/v0/publish", bytes.NewBuffer(requestBody))
|
||||
assert.NoError(t, err)
|
||||
if method == http.MethodPost {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Set auth header if provided
|
||||
// Set auth header
|
||||
if tc.authHeader != "" {
|
||||
req.Header.Set("Authorization", tc.authHeader)
|
||||
} else if tc.tokenClaims != nil {
|
||||
// Generate a valid JWT token
|
||||
token, err := generateTestJWTToken(testConfig, *tc.tokenClaims)
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
// Create response recorder
|
||||
// Perform request
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Call the handler through the mux
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
// Check status code
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code)
|
||||
// Assertions
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code, "status code mismatch")
|
||||
|
||||
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
|
||||
// Verify mock expectations
|
||||
mockRegistry.AssertExpectations(t)
|
||||
mockAuthService.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishEndpointBearerTokenParsing(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: "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)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
v0.RegisterPublishEndpoint(api, mockRegistry, mockAuthService)
|
||||
|
||||
serverDetail := model.PublishRequest{
|
||||
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.NewRequestWithContext(context.Background(), http.MethodPost, "/v0/publish", bytes.NewBuffer(requestBody))
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Authorization", tc.authHeader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
mockAuthService.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishEndpointAuthMethodSelection(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)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
|
||||
v0.RegisterPublishEndpoint(api, mockRegistry, mockAuthService)
|
||||
|
||||
serverDetail := model.PublishRequest{
|
||||
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.NewRequestWithContext(context.Background(), http.MethodPost, "/v0/publish", bytes.NewBuffer(requestBody))
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer test_token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
mockAuthService.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -20,4 +20,4 @@ type Response[T any] struct {
|
|||
// return &Response[HealthBody]{
|
||||
// Body: HealthBody{...},
|
||||
// }, nil
|
||||
// }
|
||||
// }
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue