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
201 lines
7.3 KiB
Go
201 lines
7.3 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
mcplib "github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/zzet/gortex/internal/forge"
|
|
"github.com/zzet/gortex/internal/review"
|
|
)
|
|
|
|
// callPostReview invokes the post_review handler directly with the given args.
|
|
func callPostReview(t *testing.T, srv *Server, args map[string]any) *mcplib.CallToolResult {
|
|
t.Helper()
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Name = "post_review"
|
|
req.Params.Arguments = args
|
|
res, err := srv.handlePostReview(context.Background(), req)
|
|
require.NoError(t, err)
|
|
return res
|
|
}
|
|
|
|
type postReviewOut struct {
|
|
Posted int `json:"posted"`
|
|
Skipped int `json:"skipped"`
|
|
Redacted int `json:"redacted"`
|
|
Total int `json:"total"`
|
|
DryRun bool `json:"dry_run"`
|
|
ReviewURL string `json:"review_url"`
|
|
Payloads []map[string]any `json:"payloads"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func decodePostReview(t *testing.T, res *mcplib.CallToolResult) postReviewOut {
|
|
t.Helper()
|
|
require.False(t, res.IsError, "errored: %v", res)
|
|
var out postReviewOut
|
|
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &out))
|
|
return out
|
|
}
|
|
|
|
func findingsJSON(t *testing.T, findings []review.Finding) string {
|
|
t.Helper()
|
|
b, err := json.Marshal(findings)
|
|
require.NoError(t, err)
|
|
return string(b)
|
|
}
|
|
|
|
// TestPostReview_DryRunReturnsRedactedPayload drives the real review.PostFindings
|
|
// (no forge seam swap) on a dry run: the planted secret must be absent from the
|
|
// returned payload and no network call happens (dry run never reaches the forge).
|
|
func TestPostReview_DryRunReturnsRedactedPayload(t *testing.T) {
|
|
dir, _ := reviewGitRepo(t)
|
|
srv := indexedSiblingServer(t, dir)
|
|
|
|
const secret = "AKIA0123456789ABCDEF"
|
|
findings := []review.Finding{
|
|
{Rule: "secret", Severity: review.SevError, Category: "security", File: "pkg/a.go", Line: 5,
|
|
Message: "hard-coded credential", Body: "leaked key inline: " + secret},
|
|
{Rule: "nil-deref", Severity: review.SevWarning, Category: "correctness", File: "pkg/b.go", Line: 9,
|
|
Message: "nil deref", Body: "x may be nil"},
|
|
}
|
|
|
|
out := decodePostReview(t, callPostReview(t, srv, map[string]any{
|
|
"number": 1,
|
|
"findings": findingsJSON(t, findings),
|
|
"dry_run": true,
|
|
"refuse_on_secret": false,
|
|
}))
|
|
|
|
require.True(t, out.DryRun)
|
|
require.Equal(t, 1, out.Redacted, "the secret-bearing finding must be redacted")
|
|
require.Equal(t, 2, out.Posted, "both findings build into the would-post payload")
|
|
require.Len(t, out.Payloads, 2)
|
|
for _, p := range out.Payloads {
|
|
body, _ := p["body"].(string)
|
|
require.NotContains(t, body, secret, "secret leaked into dry-run payload")
|
|
require.Equal(t, "RIGHT", p["side"])
|
|
}
|
|
}
|
|
|
|
// TestPostReview_RealPathPostsViaStubbedForgeSeam swaps the post-seam and asserts
|
|
// the real (non-dry-run) path reaches review.PostFindings, which maps the
|
|
// findings to RIGHT-side comments — no network.
|
|
func TestPostReview_RealPathPostsViaStubbedForgeSeam(t *testing.T) {
|
|
dir, _ := reviewGitRepo(t)
|
|
srv := indexedSiblingServer(t, dir)
|
|
|
|
var got []forge.ReviewComment
|
|
var gotNum int
|
|
orig := postReviewFindings
|
|
postReviewFindings = func(_ context.Context, _ string, target review.PostTarget, findings []review.Finding, opts review.PostOptions) (review.PostResult, error) {
|
|
require.False(t, opts.DryRun, "live path must not be a dry run")
|
|
gotNum = target.PRNumber
|
|
// Exercise the real mapping + redaction without networking: build the
|
|
// would-post comments exactly as the live post would.
|
|
got = nil
|
|
for _, f := range findings {
|
|
clean, _ := review.RedactSecrets(review.RenderCommentBody(f))
|
|
end := f.EndLine
|
|
if end == 0 {
|
|
end = f.Line
|
|
}
|
|
start := f.StartLine
|
|
c := forge.ReviewComment{Path: f.File, Line: end, Side: "RIGHT", Body: clean}
|
|
if start > 0 && start < end {
|
|
c.StartLine = start
|
|
}
|
|
got = append(got, c)
|
|
}
|
|
return review.PostResult{Posted: len(got)}, nil
|
|
}
|
|
t.Cleanup(func() { postReviewFindings = orig })
|
|
|
|
findings := []review.Finding{
|
|
{Rule: "nil-deref", Severity: review.SevError, File: "pkg/a.go", Line: 14, Body: "deref of nil"},
|
|
{Rule: "race", Severity: review.SevWarning, File: "pkg/b.go", StartLine: 20, EndLine: 25, Body: "data race"},
|
|
}
|
|
|
|
out := decodePostReview(t, callPostReview(t, srv, map[string]any{
|
|
"number": 42,
|
|
"findings": findingsJSON(t, findings),
|
|
}))
|
|
|
|
require.Equal(t, 42, gotNum)
|
|
require.False(t, out.DryRun)
|
|
require.Equal(t, 2, out.Posted)
|
|
require.Len(t, got, 2)
|
|
for _, c := range got {
|
|
require.Equal(t, "RIGHT", c.Side)
|
|
require.NotEmpty(t, c.Path)
|
|
require.Greater(t, c.Line, 0)
|
|
}
|
|
// Multi-line finding carries start_line < line.
|
|
require.Equal(t, 20, got[1].StartLine)
|
|
require.Equal(t, 25, got[1].Line)
|
|
}
|
|
|
|
// TestPostReview_PublicRepoRefusedWithoutAllowPublic asserts a public target is
|
|
// refused (structured error, nothing posted) unless confirm_public is set. The
|
|
// seam runs the REAL review.PostFindings gate, so the refusal is the library's,
|
|
// proving the tool threads public + confirm_public through correctly.
|
|
func TestPostReview_PublicRepoRefusedWithoutAllowPublic(t *testing.T) {
|
|
dir, _ := reviewGitRepo(t)
|
|
srv := indexedSiblingServer(t, dir)
|
|
|
|
sawAllowPublic := false
|
|
orig := postReviewFindings
|
|
postReviewFindings = func(ctx context.Context, repoDir string, target review.PostTarget, findings []review.Finding, opts review.PostOptions) (review.PostResult, error) {
|
|
sawAllowPublic = opts.AllowPublic
|
|
require.True(t, target.Public, "tool must thread public:true into the target")
|
|
// Run the real gate, but with a no-op network call so a permitted post
|
|
// does not reach a forge. The gate runs before any network on the dry-run
|
|
// path, so a dry run exercises the public/fork refusal without networking.
|
|
opts.DryRun = true
|
|
return review.PostFindings(ctx, repoDir, target, findings, opts)
|
|
}
|
|
t.Cleanup(func() { postReviewFindings = orig })
|
|
|
|
findings := []review.Finding{{Rule: "x", Severity: review.SevWarning, File: "a.go", Line: 1, Body: "f"}}
|
|
|
|
// Public, no confirm_public → refused by the real gate.
|
|
out := decodePostReview(t, callPostReview(t, srv, map[string]any{
|
|
"number": 3,
|
|
"findings": findingsJSON(t, findings),
|
|
"public": true,
|
|
}))
|
|
require.False(t, sawAllowPublic, "confirm_public not set → AllowPublic must be false")
|
|
require.Contains(t, strings.ToLower(out.Error), "public")
|
|
require.Equal(t, 0, out.Posted)
|
|
|
|
// Public + confirm_public → AllowPublic threaded through, gate permits.
|
|
out = decodePostReview(t, callPostReview(t, srv, map[string]any{
|
|
"number": 3,
|
|
"findings": findingsJSON(t, findings),
|
|
"public": true,
|
|
"confirm_public": true,
|
|
}))
|
|
require.True(t, sawAllowPublic, "confirm_public:true must set AllowPublic")
|
|
require.Empty(t, out.Error)
|
|
require.Equal(t, 1, out.Posted)
|
|
}
|
|
|
|
// TestPostReview_RegisteredEagerly asserts post_review is in the eager (hot) set.
|
|
func TestPostReview_RegisteredEagerly(t *testing.T) {
|
|
require.True(t, hotEagerTools["post_review"],
|
|
"post_review must be eagerly registered (hot), not deferred")
|
|
|
|
t.Setenv("GORTEX_LAZY_TOOLS", "1")
|
|
srv, _ := setupTestServer(t)
|
|
live := srv.mcpServer.ListTools()
|
|
require.Contains(t, live, "post_review",
|
|
"eager post_review tool must appear in tools/list without tools_search expansion")
|
|
require.False(t, srv.lazy.IsDeferred("post_review"),
|
|
"post_review must not be deferred")
|
|
}
|