191 lines
5.5 KiB
Go
191 lines
5.5 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
|
"github.com/github/github-mcp-server/pkg/utils"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// mockScopeFetcher is a mock implementation of scopes.FetcherInterface
|
|
type mockScopeFetcher struct {
|
|
scopes []string
|
|
err error
|
|
}
|
|
|
|
func (m *mockScopeFetcher) FetchTokenScopes(_ context.Context, _ string) ([]string, error) {
|
|
return m.scopes, m.err
|
|
}
|
|
|
|
func TestWithPATScopes(t *testing.T) {
|
|
logger := slog.Default()
|
|
|
|
tests := []struct {
|
|
name string
|
|
tokenInfo *ghcontext.TokenInfo
|
|
fetcherScopes []string
|
|
fetcherErr error
|
|
expectScopesFetched bool
|
|
expectedScopes []string
|
|
expectNextHandlerCalled bool
|
|
}{
|
|
{
|
|
name: "no token info in context calls next handler",
|
|
tokenInfo: nil,
|
|
expectScopesFetched: false,
|
|
expectedScopes: nil,
|
|
expectNextHandlerCalled: true,
|
|
},
|
|
{
|
|
name: "non-PAT token type skips scope fetching",
|
|
tokenInfo: &ghcontext.TokenInfo{
|
|
Token: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
TokenType: utils.TokenTypeOAuthAccessToken,
|
|
},
|
|
expectScopesFetched: false,
|
|
expectedScopes: nil,
|
|
expectNextHandlerCalled: true,
|
|
},
|
|
{
|
|
name: "fine-grained PAT skips scope fetching",
|
|
tokenInfo: &ghcontext.TokenInfo{
|
|
Token: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
|
|
TokenType: utils.TokenTypeFineGrainedPersonalAccessToken,
|
|
},
|
|
expectScopesFetched: false,
|
|
expectedScopes: nil,
|
|
expectNextHandlerCalled: true,
|
|
},
|
|
{
|
|
name: "classic PAT fetches and stores scopes",
|
|
tokenInfo: &ghcontext.TokenInfo{
|
|
Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
TokenType: utils.TokenTypePersonalAccessToken,
|
|
},
|
|
fetcherScopes: []string{"repo", "user", "read:org"},
|
|
expectScopesFetched: true,
|
|
expectedScopes: []string{"repo", "user", "read:org"},
|
|
expectNextHandlerCalled: true,
|
|
},
|
|
{
|
|
name: "classic PAT with empty scopes",
|
|
tokenInfo: &ghcontext.TokenInfo{
|
|
Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
TokenType: utils.TokenTypePersonalAccessToken,
|
|
},
|
|
fetcherScopes: []string{},
|
|
expectScopesFetched: true,
|
|
expectedScopes: []string{},
|
|
expectNextHandlerCalled: true,
|
|
},
|
|
{
|
|
name: "fetcher error calls next handler without scopes",
|
|
tokenInfo: &ghcontext.TokenInfo{
|
|
Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
TokenType: utils.TokenTypePersonalAccessToken,
|
|
},
|
|
fetcherErr: errors.New("network error"),
|
|
expectScopesFetched: false,
|
|
expectedScopes: nil,
|
|
expectNextHandlerCalled: true,
|
|
},
|
|
{
|
|
name: "old-style PAT (40 hex chars) fetches scopes",
|
|
tokenInfo: &ghcontext.TokenInfo{
|
|
Token: "0123456789abcdef0123456789abcdef01234567",
|
|
TokenType: utils.TokenTypePersonalAccessToken,
|
|
},
|
|
fetcherScopes: []string{"repo"},
|
|
expectScopesFetched: true,
|
|
expectedScopes: []string{"repo"},
|
|
expectNextHandlerCalled: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var capturedScopes []string
|
|
var scopesFound bool
|
|
var nextHandlerCalled bool
|
|
|
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
nextHandlerCalled = true
|
|
capturedScopes, scopesFound = ghcontext.GetTokenScopes(r.Context())
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
fetcher := &mockScopeFetcher{
|
|
scopes: tt.fetcherScopes,
|
|
err: tt.fetcherErr,
|
|
}
|
|
|
|
middleware := WithPATScopes(logger, fetcher)
|
|
handler := middleware(nextHandler)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
|
|
// Set up context with token info if provided
|
|
if tt.tokenInfo != nil {
|
|
ctx := ghcontext.WithTokenInfo(req.Context(), tt.tokenInfo)
|
|
req = req.WithContext(ctx)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, tt.expectNextHandlerCalled, nextHandlerCalled, "next handler called mismatch")
|
|
|
|
if tt.expectNextHandlerCalled {
|
|
assert.Equal(t, tt.expectScopesFetched, scopesFound, "scopes found mismatch")
|
|
assert.Equal(t, tt.expectedScopes, capturedScopes)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWithPATScopes_PreservesExistingTokenInfo(t *testing.T) {
|
|
logger := slog.Default()
|
|
|
|
var capturedTokenInfo *ghcontext.TokenInfo
|
|
var capturedScopes []string
|
|
var scopesFound bool
|
|
|
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
capturedTokenInfo, _ = ghcontext.GetTokenInfo(r.Context())
|
|
capturedScopes, scopesFound = ghcontext.GetTokenScopes(r.Context())
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
fetcher := &mockScopeFetcher{
|
|
scopes: []string{"repo", "user"},
|
|
}
|
|
|
|
originalTokenInfo := &ghcontext.TokenInfo{
|
|
Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
TokenType: utils.TokenTypePersonalAccessToken,
|
|
}
|
|
|
|
middleware := WithPATScopes(logger, fetcher)
|
|
handler := middleware(nextHandler)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
ctx := ghcontext.WithTokenInfo(req.Context(), originalTokenInfo)
|
|
req = req.WithContext(ctx)
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
require.NotNil(t, capturedTokenInfo)
|
|
assert.Equal(t, originalTokenInfo.Token, capturedTokenInfo.Token)
|
|
assert.Equal(t, originalTokenInfo.TokenType, capturedTokenInfo.TokenType)
|
|
assert.True(t, scopesFound)
|
|
assert.Equal(t, []string{"repo", "user"}, capturedScopes)
|
|
}
|