8ec62491c6
* feat(repos): add confirmed repository deletion Add a destructive delete_repository tool that requires an exact owner/repo confirmation through multi-round-trip elicitation. Gate the tool to MCP protocol 2026-07-28 and newer across local and remote transports. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * refactor(inventory): generalize tool availability guards Gate protocol-restricted tools on required elicitation capabilities and enforce direct calls inside the registered handler so SDK result finalization remains intact. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * feat(http): protect MRTR request state Seal repository deletion targets for self-hosted HTTP with a stable AES-256-GCM key. Hide only delete_repository when no key is configured and expose an optional sealer interface for remote integrators. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(repos): expire deletion confirmations Bind sealed repository deletion state to the immutable repository ID and a ten-minute expiry. Re-check identity before deletion so replay cannot affect a recreated repository. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(http): preserve tool and scope restrictions Apply static allowlists before removing unavailable tools and fail closed on invalid configured tool names. Model independent OAuth requirements as conjunctive groups so repository deletion requires both delete_repo and repo. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(repos): require protected confirmation state Give stdio a process-local request-state sealer and make deletion fail closed without one. Preserve legacy any-of OAuth behavior globally while documenting and enforcing delete_repository's conjunctive delete_repo and repo requirements. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(oauth): request repository deletion scope Include delete_repo in the supported OAuth scope set used by stdio login, HTTP protected-resource metadata, and tool filtering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(oauth): require deletion scope opt-in Keep delete_repo in protected-resource discovery for step-up authorization while excluding it from the default stdio OAuth grant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * refactor(oauth): derive scope sets from catalog Generate protected-resource supported scopes and the lower-risk default OAuth grant from one canonical scope definition list. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * refactor(scopes): own OAuth scope catalog Move supported and default OAuth scope policy into pkg/scopes so protected-resource metadata and stdio grants derive from the scope domain package. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(scopes): require workflow scope opt-in Keep workflow and codespace in protected-resource discovery while excluding both from the default OAuth grant alongside delete_repo. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 --------- Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
359 lines
9.1 KiB
Go
359 lines
9.1 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: "delete_repo returns just delete_repo",
|
|
required: []Scope{DeleteRepo},
|
|
expected: []string{"delete_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 TestHasRequiredScopeGroups(t *testing.T) {
|
|
groups := ExpandScopeGroups(DeleteRepo, Repo)
|
|
|
|
assert.True(t, HasRequiredScopeGroups([]string{"delete_repo", "repo"}, groups))
|
|
assert.False(t, HasRequiredScopeGroups([]string{"delete_repo"}, groups))
|
|
assert.False(t, HasRequiredScopeGroups([]string{"repo"}, groups))
|
|
}
|
|
|
|
func TestOAuthScopeCatalog(t *testing.T) {
|
|
supported := SupportedOAuthScopes()
|
|
defaults := DefaultOAuthScopes()
|
|
|
|
assert.Subset(t, supported, defaults)
|
|
assert.Contains(t, supported, string(DeleteRepo))
|
|
assert.NotContains(t, defaults, string(DeleteRepo))
|
|
assert.Contains(t, supported, string(Workflow))
|
|
assert.NotContains(t, defaults, string(Workflow))
|
|
assert.Contains(t, supported, string(Codespace))
|
|
assert.NotContains(t, defaults, string(Codespace))
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|
|
}
|