dde00d0d8c
Add per-call OAuth scope resolution for workflow paths and reject unsafe repository-relative paths before file writes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
141 lines
4.2 KiB
Go
141 lines
4.2 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/github/github-mcp-server/pkg/inventory"
|
|
"github.com/github/github-mcp-server/pkg/translations"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestValidateRelativePath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
want string
|
|
wantErr string
|
|
}{
|
|
{name: "file", value: "docs/readme.md", want: "docs/readme.md"},
|
|
{name: "normalizes dot segment", value: "./.github/workflows/ci.yml", want: ".github/workflows/ci.yml"},
|
|
{name: "normalizes duplicate separator", value: ".github//workflows/ci.yml", want: ".github/workflows/ci.yml"},
|
|
{name: "empty", value: "", wantErr: "must not be empty"},
|
|
{name: "current directory", value: ".", wantErr: "must identify a file"},
|
|
{name: "absolute", value: "/.github/workflows/ci.yml", wantErr: "must be relative"},
|
|
{name: "parent traversal", value: "docs/../.github/workflows/ci.yml", wantErr: "parent directory traversal"},
|
|
{name: "leading traversal", value: "../.github/workflows/ci.yml", wantErr: "parent directory traversal"},
|
|
{name: "backslash traversal", value: `docs\..\.github\workflows\ci.yml`, wantErr: "forward slashes"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := validateRelativePath(tt.value)
|
|
if tt.wantErr != "" {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.wantErr)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFileWriteWorkflowScopeResolvers(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
tool inventory.ServerTool
|
|
args map[string]any
|
|
want []string
|
|
}{
|
|
{
|
|
name: "create regular file",
|
|
tool: CreateOrUpdateFile(translations.NullTranslationHelper),
|
|
args: map[string]any{"path": "docs/readme.md"},
|
|
},
|
|
{
|
|
name: "create workflow",
|
|
tool: CreateOrUpdateFile(translations.NullTranslationHelper),
|
|
args: map[string]any{"path": ".github/workflows/ci.yml"},
|
|
want: []string{"workflow"},
|
|
},
|
|
{
|
|
name: "delete normalized workflow",
|
|
tool: DeleteFile(translations.NullTranslationHelper),
|
|
args: map[string]any{"path": "./.github/workflows/ci.yml"},
|
|
want: []string{"workflow"},
|
|
},
|
|
{
|
|
name: "reject traversal instead of resolving it",
|
|
tool: DeleteFile(translations.NullTranslationHelper),
|
|
args: map[string]any{"path": "docs/../.github/workflows/ci.yml"},
|
|
},
|
|
{
|
|
name: "push regular files",
|
|
tool: PushFiles(translations.NullTranslationHelper),
|
|
args: map[string]any{"files": []any{map[string]any{"path": "README.md"}}},
|
|
},
|
|
{
|
|
name: "push includes workflow",
|
|
tool: PushFiles(translations.NullTranslationHelper),
|
|
args: map[string]any{"files": []any{
|
|
map[string]any{"path": "README.md"},
|
|
map[string]any{"path": ".github/workflows/ci.yml"},
|
|
}},
|
|
want: []string{"workflow"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
require.NotNil(t, tt.tool.ScopeResolver)
|
|
assert.Equal(t, tt.want, tt.tool.ScopeResolver(tt.args))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFileWriteToolsRejectUnsafePathsBeforeAPICalls(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
tool inventory.ServerTool
|
|
args map[string]any
|
|
}{
|
|
{
|
|
name: "create or update",
|
|
tool: CreateOrUpdateFile(translations.NullTranslationHelper),
|
|
args: map[string]any{
|
|
"owner": "owner", "repo": "repo", "path": "../workflow.yml",
|
|
"content": "content", "message": "message", "branch": "main",
|
|
},
|
|
},
|
|
{
|
|
name: "delete",
|
|
tool: DeleteFile(translations.NullTranslationHelper),
|
|
args: map[string]any{
|
|
"owner": "owner", "repo": "repo", "path": "/.github/workflows/ci.yml",
|
|
"message": "message", "branch": "main",
|
|
},
|
|
},
|
|
{
|
|
name: "push",
|
|
tool: PushFiles(translations.NullTranslationHelper),
|
|
args: map[string]any{
|
|
"owner": "owner", "repo": "repo", "branch": "main", "message": "message",
|
|
"files": []any{map[string]any{"path": `..\workflow.yml`, "content": "content"}},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
deps := BaseDeps{}
|
|
request := createMCPRequest(tt.args)
|
|
result, err := tt.tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
assert.Contains(t, getErrorResult(t, result).Text, "path")
|
|
})
|
|
}
|
|
}
|