|
| 1 | +package github |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "testing" |
| 6 | + |
| 7 | + ghoauth "github.com/github/github-mcp-server/pkg/http/oauth" |
| 8 | + "github.com/github/github-mcp-server/pkg/inventory" |
| 9 | + "github.com/github/github-mcp-server/pkg/scopes" |
| 10 | + "github.com/github/github-mcp-server/pkg/translations" |
| 11 | + "github.com/modelcontextprotocol/go-sdk/mcp" |
| 12 | + "github.com/stretchr/testify/assert" |
| 13 | + "github.com/stretchr/testify/require" |
| 14 | +) |
| 15 | + |
| 16 | +// This file pins the deliberate, non-obvious assumptions baked into the OAuth |
| 17 | +// scope model so they stay visible to developers revisiting them. Each test |
| 18 | +// documents WHAT we assume, WHY, and the escape hatch if the assumption ever |
| 19 | +// needs to change. If one of these fails, treat it as a prompt to make a |
| 20 | +// conscious decision, not to silence the test. |
| 21 | + |
| 22 | +// TestAssumption_PATShowsRepoToolsButOAuthChallengesForRepo encodes the |
| 23 | +// intentional asymmetry between the two enforcement paths for a read-only tool |
| 24 | +// whose only requirement is repo-ish access: |
| 25 | +// |
| 26 | +// - PAT filtering (CreateToolScopeFilter) is best-effort and keeps such tools |
| 27 | +// VISIBLE even when the token advertises no scopes, because they still work |
| 28 | +// against PUBLIC repositories and that access is useful. |
| 29 | +// - The OAuth scope-challenge path (scopes.ToolScopeInfo.Satisfies / |
| 30 | +// HasRequiredScopes) has NO such exception: it treats `repo` as genuinely |
| 31 | +// required and will challenge the user to grant it. |
| 32 | +// |
| 33 | +// In other words: we'd rather show-and-let-the-API-decide for PATs, but |
| 34 | +// proactively request the scope for OAuth where challenging is cheap and clean. |
| 35 | +func TestAssumption_PATShowsRepoToolsButOAuthChallengesForRepo(t *testing.T) { |
| 36 | + readOnlyRepoTool := &inventory.ServerTool{ |
| 37 | + Tool: mcp.Tool{ |
| 38 | + Name: "read_only_repo_tool", |
| 39 | + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, |
| 40 | + }, |
| 41 | + RequiredScopes: []string{"repo"}, |
| 42 | + } |
| 43 | + |
| 44 | + // PAT side: shown even with no token scopes (public-repo access is useful). |
| 45 | + patFilter := CreateToolScopeFilter([]string{}) |
| 46 | + shown, err := patFilter(context.Background(), readOnlyRepoTool) |
| 47 | + require.NoError(t, err) |
| 48 | + assert.True(t, shown, "PAT filtering should keep a read-only repo tool visible without any scope (public repo access)") |
| 49 | + |
| 50 | + // OAuth side: the same requirement is NOT satisfied by an empty scope set, |
| 51 | + // so the challenge middleware would request `repo`. |
| 52 | + assert.False(t, scopes.HasRequiredScopes([]string{}, []string{"repo"}), |
| 53 | + "OAuth challenge model must treat repo as required (no public-repo exception)") |
| 54 | + info := &scopes.ToolScopeInfo{RequiredScopes: []string{"repo"}} |
| 55 | + assert.False(t, info.Satisfies(), |
| 56 | + "Satisfies must report unsatisfied for a repo tool with no granted scopes (triggers a challenge)") |
| 57 | + assert.Equal(t, []string{"repo"}, info.MissingScopes(), |
| 58 | + "the challenge should ask for exactly the missing repo scope") |
| 59 | +} |
| 60 | + |
| 61 | +// TestAssumption_WorkflowScopeIsGrantableButNeverChallenged encodes that the |
| 62 | +// `workflow` scope is intentionally reachable only as an up-front grant, never |
| 63 | +// via an on-demand scope challenge: |
| 64 | +// |
| 65 | +// - It IS advertised in oauth.SupportedScopes, so a classic PAT can carry it |
| 66 | +// and the default OAuth login can request it up front. |
| 67 | +// - But NO tool declares it as a required scope, so the challenge path can |
| 68 | +// never ask for it on demand. (There is also deliberately no scopes.Workflow |
| 69 | +// constant, so a tool cannot declare it via the typed API without someone |
| 70 | +// first adding the constant.) |
| 71 | +// |
| 72 | +// This is a conscious risk-aversion choice: `workflow` grants control over |
| 73 | +// GitHub Actions workflow files, so we don't auto-request it. If a tool ever |
| 74 | +// genuinely needs it, the path is: add a scopes.Workflow constant, declare it on |
| 75 | +// the tool, and accept that the challenge will then request `workflow` (it is |
| 76 | +// already in SupportedScopes, so the mechanics work). This test will fail at |
| 77 | +// that point to force that decision to be made deliberately. |
| 78 | +func TestAssumption_WorkflowScopeIsGrantableButNeverChallenged(t *testing.T) { |
| 79 | + assert.Contains(t, ghoauth.SupportedScopes, "workflow", |
| 80 | + "workflow should remain a supported/grantable scope (PATs carry it; OAuth can request it up front)") |
| 81 | + |
| 82 | + inv, err := NewInventory(translations.NullTranslationHelper). |
| 83 | + WithToolsets([]string{"all"}). |
| 84 | + Build() |
| 85 | + require.NoError(t, err) |
| 86 | + |
| 87 | + for _, tool := range inv.AllTools() { |
| 88 | + assert.NotContains(t, tool.RequiredScopes, "workflow", |
| 89 | + "tool %q declares the workflow scope as required; the OAuth challenge path would then request it. "+ |
| 90 | + "That is an intentional escape hatch — update this test and confirm the risk is acceptable.", tool.Tool.Name) |
| 91 | + } |
| 92 | +} |
| 93 | + |
| 94 | +// TestAssumption_MultiScopeRequirementsAreTreatedAsAND encodes that when a tool |
| 95 | +// declares more than one required scope we treat them as a conjunction (ALL |
| 96 | +// required), because the declaration ([]scopes.Scope) cannot express "any of". |
| 97 | +// We cannot distinguish a genuine hard-AND from a genuine hard-OR, so we |
| 98 | +// conservatively require all of them. Hierarchy substitution still applies, so |
| 99 | +// an ancestor scope satisfies a required descendant. |
| 100 | +// |
| 101 | +// If a real OR requirement ever appears, the escape hatch is to extend the |
| 102 | +// model to OR-groups (AND across groups, OR within a group); see |
| 103 | +// scopes.HasRequiredScopes. Until then, AND is the deliberate default. |
| 104 | +func TestAssumption_MultiScopeRequirementsAreTreatedAsAND(t *testing.T) { |
| 105 | + required := []string{"repo", "read:org"} |
| 106 | + |
| 107 | + // AND: one of the two scopes is not enough. |
| 108 | + assert.False(t, scopes.HasRequiredScopes([]string{"repo"}, required), |
| 109 | + "a token with only repo must NOT satisfy a {repo, read:org} tool (treated as AND, not OR)") |
| 110 | + |
| 111 | + // Both scopes present satisfies the conjunction. |
| 112 | + assert.True(t, scopes.HasRequiredScopes([]string{"repo", "read:org"}, required), |
| 113 | + "a token holding both required scopes satisfies the conjunction") |
| 114 | + |
| 115 | + // Hierarchy substitution still applies on top of AND: admin:org grants read:org. |
| 116 | + assert.True(t, scopes.HasRequiredScopes([]string{"repo", "admin:org"}, required), |
| 117 | + "an ancestor scope (admin:org) still satisfies a required descendant (read:org) under AND") |
| 118 | +} |
0 commit comments