Files
github--github-mcp-server/pkg/scopes/scopes_test.go
Sam Morrow 46b8cb63ac Add PAT scope filtering for stdio server
Add the ability to filter tools based on token scopes for PAT users.
This uses an HTTP HEAD request to GitHub's API to discover token scopes.

New components:
- pkg/scopes/filter.go: HasRequiredScopes checks if scopes satisfy tool requirements
- pkg/scopes/fetcher.go: FetchTokenScopes gets scopes via HTTP HEAD to GitHub API
- pkg/github/scope_filter.go: CreateScopeFilter creates inventory.ToolFilter

Integration:
- Add --filter-by-scope flag to stdio command (disabled by default)
- When enabled, fetches token scopes on startup
- Tools requiring unavailable scopes are hidden from tool list
- Gracefully continues without filtering if scope fetch fails (logs warning)

This allows the OSS server to have similar scope-based tool visibility
as the remote server, and the filter logic can be reused by remote server.
2026-01-05 16:05:24 +00:00

333 lines
8.2 KiB
Go

package scopes
import (
"sort"
"testing"
"github.com/stretchr/testify/assert"
)
func TestExpandScopes(t *testing.T) {
tests := []struct {
name string
required []Scope
expected []string
}{
{
name: "nil returns nil",
required: nil,
expected: nil,
},
{
name: "empty returns nil",
required: []Scope{},
expected: nil,
},
{
name: "repo scope returns just repo",
required: []Scope{Repo},
expected: []string{"repo"},
},
{
name: "public_repo also accepts repo (parent)",
required: []Scope{PublicRepo},
expected: []string{"public_repo", "repo"},
},
{
name: "security_events also accepts repo (parent)",
required: []Scope{SecurityEvents},
expected: []string{"repo", "security_events"},
},
{
name: "read:org also accepts write:org and admin:org (parents)",
required: []Scope{ReadOrg},
expected: []string{"admin:org", "read:org", "write:org"},
},
{
name: "write:org also accepts admin:org (parent)",
required: []Scope{WriteOrg},
expected: []string{"admin:org", "write:org"},
},
{
name: "admin:org returns just admin:org (no parent)",
required: []Scope{AdminOrg},
expected: []string{"admin:org"},
},
{
name: "read:project also accepts project (parent)",
required: []Scope{ReadProject},
expected: []string{"project", "read:project"},
},
{
name: "project returns just project (no parent)",
required: []Scope{Project},
expected: []string{"project"},
},
{
name: "gist returns just gist (no parent)",
required: []Scope{Gist},
expected: []string{"gist"},
},
{
name: "notifications returns just notifications (no parent)",
required: []Scope{Notifications},
expected: []string{"notifications"},
},
{
name: "read:packages also accepts write:packages (parent)",
required: []Scope{ReadPackages},
expected: []string{"read:packages", "write:packages"},
},
{
name: "read:user also accepts user (parent)",
required: []Scope{ReadUser},
expected: []string{"read:user", "user"},
},
{
name: "multiple scopes combine correctly",
required: []Scope{PublicRepo, ReadOrg},
expected: []string{"admin:org", "public_repo", "read:org", "repo", "write:org"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExpandScopes(tt.required...)
// Sort both for consistent comparison
if result != nil {
sort.Strings(result)
}
if tt.expected != nil {
sort.Strings(tt.expected)
}
assert.Equal(t, tt.expected, result)
})
}
}
func TestToStringSlice(t *testing.T) {
tests := []struct {
name string
scopes []Scope
expected []string
}{
{
name: "empty returns empty",
scopes: []Scope{},
expected: []string{},
},
{
name: "single scope",
scopes: []Scope{Repo},
expected: []string{"repo"},
},
{
name: "multiple scopes",
scopes: []Scope{Repo, Gist, ReadOrg},
expected: []string{"repo", "gist", "read:org"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ToStringSlice(tt.scopes...)
assert.Equal(t, tt.expected, result)
})
}
}
func TestScopeHierarchy(t *testing.T) {
// Verify the hierarchy is correctly defined
assert.Contains(t, ScopeHierarchy[Repo], PublicRepo)
assert.Contains(t, ScopeHierarchy[Repo], SecurityEvents)
assert.Contains(t, ScopeHierarchy[AdminOrg], WriteOrg)
assert.Contains(t, ScopeHierarchy[AdminOrg], ReadOrg)
assert.Contains(t, ScopeHierarchy[WriteOrg], ReadOrg)
assert.Contains(t, ScopeHierarchy[Project], ReadProject)
assert.Contains(t, ScopeHierarchy[WritePackages], ReadPackages)
assert.Contains(t, ScopeHierarchy[User], ReadUser)
assert.Contains(t, ScopeHierarchy[User], UserEmail)
}
func TestExpandScopeSet(t *testing.T) {
tests := []struct {
name string
scopes []string
expected map[string]bool
}{
{
name: "empty scopes",
scopes: []string{},
expected: map[string]bool{},
},
{
name: "repo expands to include public_repo and security_events",
scopes: []string{"repo"},
expected: map[string]bool{
"repo": true,
"public_repo": true,
"security_events": true,
},
},
{
name: "admin:org expands to include write:org and read:org",
scopes: []string{"admin:org"},
expected: map[string]bool{
"admin:org": true,
"write:org": true,
"read:org": true,
},
},
{
name: "write:org expands to include read:org",
scopes: []string{"write:org"},
expected: map[string]bool{
"write:org": true,
"read:org": true,
},
},
{
name: "user expands to include read:user and user:email",
scopes: []string{"user"},
expected: map[string]bool{
"user": true,
"read:user": true,
"user:email": true,
},
},
{
name: "scope without children stays as-is",
scopes: []string{"gist"},
expected: map[string]bool{
"gist": true,
},
},
{
name: "multiple scopes combine correctly",
scopes: []string{"repo", "gist"},
expected: map[string]bool{
"repo": true,
"public_repo": true,
"security_events": true,
"gist": true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := expandScopeSet(tt.scopes)
assert.Equal(t, tt.expected, result)
})
}
}
func TestHasRequiredScopes(t *testing.T) {
tests := []struct {
name string
tokenScopes []string
acceptedScopes []string
expected bool
}{
{
name: "no accepted scopes - always allowed",
tokenScopes: []string{},
acceptedScopes: []string{},
expected: true,
},
{
name: "nil accepted scopes - always allowed",
tokenScopes: []string{"repo"},
acceptedScopes: nil,
expected: true,
},
{
name: "token has exact required scope",
tokenScopes: []string{"repo"},
acceptedScopes: []string{"repo"},
expected: true,
},
{
name: "token has parent scope that grants access",
tokenScopes: []string{"repo"},
acceptedScopes: []string{"public_repo"},
expected: true,
},
{
name: "token has parent scope for security_events",
tokenScopes: []string{"repo"},
acceptedScopes: []string{"security_events"},
expected: true,
},
{
name: "token has admin:org which grants read:org",
tokenScopes: []string{"admin:org"},
acceptedScopes: []string{"read:org"},
expected: true,
},
{
name: "token has write:org which grants read:org",
tokenScopes: []string{"write:org"},
acceptedScopes: []string{"read:org"},
expected: true,
},
{
name: "token missing required scope",
tokenScopes: []string{"gist"},
acceptedScopes: []string{"repo"},
expected: false,
},
{
name: "token has child but not parent - fails",
tokenScopes: []string{"public_repo"},
acceptedScopes: []string{"repo"},
expected: false,
},
{
name: "multiple token scopes - one matches",
tokenScopes: []string{"gist", "repo"},
acceptedScopes: []string{"public_repo"},
expected: true,
},
{
name: "multiple accepted scopes - token has one",
tokenScopes: []string{"repo"},
acceptedScopes: []string{"repo", "admin:org"},
expected: true,
},
{
name: "empty token scopes - fails when scopes required",
tokenScopes: []string{},
acceptedScopes: []string{"repo"},
expected: false,
},
{
name: "user scope grants read:user",
tokenScopes: []string{"user"},
acceptedScopes: []string{"read:user"},
expected: true,
},
{
name: "user scope grants user:email",
tokenScopes: []string{"user"},
acceptedScopes: []string{"user:email"},
expected: true,
},
{
name: "write:packages grants read:packages",
tokenScopes: []string{"write:packages"},
acceptedScopes: []string{"read:packages"},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := HasRequiredScopes(tt.tokenScopes, tt.acceptedScopes)
assert.Equal(t, tt.expected, result)
})
}
}