a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
182 lines
6.4 KiB
Go
182 lines
6.4 KiB
Go
package mcp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"testing"
|
|
|
|
mcplib "github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/indexer"
|
|
"github.com/zzet/gortex/internal/query"
|
|
)
|
|
|
|
// prRiskTestServer builds a server over a synthetic graph: a security-sensitive
|
|
// hub function with many inbound callers and no covering test.
|
|
func prRiskTestServer(t *testing.T) (*Server, string) {
|
|
t.Helper()
|
|
g := graph.New()
|
|
hubID := "internal/auth/login.go::ValidateToken"
|
|
g.AddNode(&graph.Node{ID: hubID, Kind: graph.KindFunction, Name: "ValidateToken", FilePath: "internal/auth/login.go", StartLine: 1, EndLine: 10})
|
|
for i := 0; i < 12; i++ {
|
|
cid := "pkg/c.go::caller" + strconv.Itoa(i)
|
|
g.AddNode(&graph.Node{ID: cid, Kind: graph.KindFunction, Name: "caller" + strconv.Itoa(i), FilePath: "pkg/c.go"})
|
|
g.AddEdge(&graph.Edge{From: cid, To: hubID, Kind: graph.EdgeCalls})
|
|
}
|
|
srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil)
|
|
return srv, hubID
|
|
}
|
|
|
|
func callPRRisk(t *testing.T, srv *Server, args map[string]any) *mcplib.CallToolResult {
|
|
t.Helper()
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Name = "pr_risk"
|
|
req.Params.Arguments = args
|
|
res, err := srv.handlePRRisk(t.Context(), req)
|
|
require.NoError(t, err)
|
|
return res
|
|
}
|
|
|
|
func TestPRRisk_IDsPath(t *testing.T) {
|
|
srv, hubID := prRiskTestServer(t)
|
|
res := callPRRisk(t, srv, map[string]any{"ids": hubID})
|
|
require.False(t, res.IsError, "errored: %v", res)
|
|
|
|
var out struct {
|
|
Score float64 `json:"score"`
|
|
Risk string `json:"risk"`
|
|
ReviewPriorities []struct {
|
|
Axis string `json:"axis"`
|
|
Score float64 `json:"score"`
|
|
Reason string `json:"reason"`
|
|
} `json:"review_priorities"`
|
|
TotalAffected int `json:"total_affected"`
|
|
UncoveredSymbols int `json:"uncovered_symbols"`
|
|
CommunitySpan int `json:"community_span"`
|
|
SecurityHits []string `json:"security_hits"`
|
|
ChangedSymbols int `json:"changed_symbols"`
|
|
}
|
|
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &out))
|
|
|
|
// Score is a 0-100 composite.
|
|
require.GreaterOrEqual(t, out.Score, 0.0)
|
|
require.LessOrEqual(t, out.Score, 100.0)
|
|
|
|
// Risk label matches the impactRisk thresholds for the score.
|
|
require.Equal(t, impactRisk(out.Score), out.Risk)
|
|
|
|
// review_priorities non-empty and sorted descending by score.
|
|
require.NotEmpty(t, out.ReviewPriorities)
|
|
for i := 1; i < len(out.ReviewPriorities); i++ {
|
|
require.GreaterOrEqual(t, out.ReviewPriorities[i-1].Score, out.ReviewPriorities[i].Score,
|
|
"review_priorities must be sorted descending")
|
|
}
|
|
|
|
// A security path + 12-caller untested hub is at least HIGH.
|
|
require.Contains(t, []string{"HIGH", "CRITICAL"}, out.Risk)
|
|
require.Contains(t, out.SecurityHits, "auth")
|
|
require.Equal(t, 1, out.ChangedSymbols)
|
|
}
|
|
|
|
func TestPRRisk_RequiresInput(t *testing.T) {
|
|
srv, _ := prRiskTestServer(t)
|
|
res := callPRRisk(t, srv, map[string]any{})
|
|
require.True(t, res.IsError, "expected error when neither ids nor base is given")
|
|
}
|
|
|
|
func TestPRRisk_GCXAndTOONAndBudget(t *testing.T) {
|
|
srv, hubID := prRiskTestServer(t)
|
|
|
|
// GCX round-trip: the section headers must appear.
|
|
gcx := callPRRisk(t, srv, map[string]any{"ids": hubID, "format": "gcx"})
|
|
require.False(t, gcx.IsError)
|
|
gtext := gcx.Content[0].(mcplib.TextContent).Text
|
|
require.Contains(t, gtext, "pr_risk.summary")
|
|
require.Contains(t, gtext, "pr_risk.priorities")
|
|
|
|
// max_bytes budget is honoured (response stays bounded).
|
|
budgeted := callPRRisk(t, srv, map[string]any{"ids": hubID, "format": "gcx", "max_bytes": float64(120)})
|
|
require.False(t, budgeted.IsError)
|
|
require.LessOrEqual(t, len(budgeted.Content[0].(mcplib.TextContent).Text), 400)
|
|
|
|
// TOON round-trip: still carries the score key.
|
|
toon := callPRRisk(t, srv, map[string]any{"ids": hubID, "format": "toon"})
|
|
require.False(t, toon.IsError)
|
|
require.Contains(t, toon.Content[0].(mcplib.TextContent).Text, "score")
|
|
}
|
|
|
|
// prRiskGitRepo creates a git repo with a base commit on `main` and a HEAD
|
|
// commit that mutates a security-sensitive file, then returns the root.
|
|
func prRiskGitRepo(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
run := func(args ...string) {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
|
|
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t",
|
|
)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
}
|
|
}
|
|
run("init", "-b", "main")
|
|
run("config", "user.email", "t@t")
|
|
run("config", "user.name", "t")
|
|
run("config", "diff.mnemonicPrefix", "false")
|
|
run("config", "diff.noprefix", "false")
|
|
|
|
authDir := filepath.Join(dir, "internal", "auth")
|
|
require.NoError(t, os.MkdirAll(authDir, 0o755))
|
|
src := "package auth\n\nfunc ValidateToken() int {\n\treturn 1\n}\n"
|
|
require.NoError(t, os.WriteFile(filepath.Join(authDir, "login.go"), []byte(src), 0o644))
|
|
run("add", ".")
|
|
run("commit", "-m", "base")
|
|
// Tag the base commit so the diff has a ref that is strictly behind HEAD
|
|
// — a `base...HEAD` against the current branch tip would be empty.
|
|
run("tag", "base-ref")
|
|
|
|
// HEAD commit mutates the body inside ValidateToken.
|
|
mutated := "package auth\n\nfunc ValidateToken() int {\n\tx := 1\n\treturn x\n}\n"
|
|
require.NoError(t, os.WriteFile(filepath.Join(authDir, "login.go"), []byte(mutated), 0o644))
|
|
run("add", ".")
|
|
run("commit", "-m", "change")
|
|
return dir
|
|
}
|
|
|
|
func TestPRRisk_BasePath(t *testing.T) {
|
|
dir := prRiskGitRepo(t)
|
|
|
|
g := graph.New()
|
|
reg := testRegistry()
|
|
idx := indexer.New(g, reg, config.Default().Index, zap.NewNop())
|
|
_, err := idx.Index(dir)
|
|
require.NoError(t, err)
|
|
|
|
srv := NewServer(query.NewEngine(g), g, idx, nil, zap.NewNop(), nil)
|
|
srv.RunAnalysis()
|
|
|
|
res := callPRRisk(t, srv, map[string]any{"base": "base-ref"})
|
|
require.False(t, res.IsError, "errored: %v", res)
|
|
|
|
var out struct {
|
|
Risk string `json:"risk"`
|
|
SecurityHits []string `json:"security_hits"`
|
|
ChangedSymbols int `json:"changed_symbols"`
|
|
}
|
|
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &out))
|
|
|
|
// The base diff derived at least one changed symbol (ValidateToken),
|
|
// and the security axis picked up the auth path.
|
|
require.GreaterOrEqual(t, out.ChangedSymbols, 1, "expected the base diff to map a changed symbol")
|
|
require.Contains(t, out.SecurityHits, "auth")
|
|
}
|