Files
zzet--gortex/internal/mcp/tools_fileops_basesha_test.go
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

302 lines
10 KiB
Go

package mcp
import (
"os"
"path/filepath"
"testing"
mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// errorText returns the first text content block of an error result.
// Helper kept local: the drift-error message is the contract, so
// tests assert on the message text and the IsError flag.
func errorText(t *testing.T, result *mcplib.CallToolResult) string {
t.Helper()
require.NotEmpty(t, result.Content)
return result.Content[0].(mcplib.TextContent).Text
}
// fileBlobSHA reads a file and returns its git blob SHA — the
// authoritative anchor agents observe at read time. Tests use the
// same primitive the handlers use so a regression in gitBlobSHA
// surfaces as a test failure rather than a silent test-only drift.
func fileBlobSHA(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err)
return gitBlobSHA(data)
}
// --- edit_file -------------------------------------------------------------
func TestEditFile_BaseSHA_Matches_Applies(t *testing.T) {
srv, dir := setupTestServer(t)
target := filepath.Join(dir, "notes.md")
require.NoError(t, os.WriteFile(target, []byte("hello\n"), 0o644))
sha := fileBlobSHA(t, target)
result := callTool(t, srv, "edit_file", map[string]any{
"path": "notes.md",
"old_string": "hello",
"new_string": "world",
"base_sha": sha,
})
assert.False(t, result.IsError, "matching base_sha must not block the write")
resp := decodeFileOpsResult(t, result)
assert.Equal(t, "applied", resp["status"])
// new_sha is required on success and must match the post-write blob SHA.
got, err := os.ReadFile(target)
require.NoError(t, err)
assert.Equal(t, "world\n", string(got))
assert.Equal(t, gitBlobSHA(got), resp["new_sha"], "new_sha must be the SHA of the new on-disk content")
}
func TestEditFile_BaseSHA_Stale_RejectsWriteAndPreservesFile(t *testing.T) {
srv, dir := setupTestServer(t)
target := filepath.Join(dir, "notes.md")
require.NoError(t, os.WriteFile(target, []byte("original\n"), 0o644))
staleSHA := fileBlobSHA(t, target)
// A human (or another tool) edits the file between the agent's
// read and the agent's write. The drift guard must catch this.
require.NoError(t, os.WriteFile(target, []byte("human edit\n"), 0o644))
result := callTool(t, srv, "edit_file", map[string]any{
"path": "notes.md",
"old_string": "human edit",
"new_string": "agent clobber",
"base_sha": staleSHA,
})
assert.True(t, result.IsError, "stale base_sha must reject the write")
assert.Contains(t, errorText(t, result), "base_sha mismatch")
got, err := os.ReadFile(target)
require.NoError(t, err)
assert.Equal(t, "human edit\n", string(got), "the human's edit must survive — no silent clobber")
}
func TestEditFile_NoBaseSHA_BackwardCompatible(t *testing.T) {
srv, dir := setupTestServer(t)
target := filepath.Join(dir, "notes.md")
require.NoError(t, os.WriteFile(target, []byte("hello\n"), 0o644))
// Mutate the file out of band — without base_sha the call must
// still proceed (legacy behaviour: trust the caller).
require.NoError(t, os.WriteFile(target, []byte("changed\n"), 0o644))
result := callTool(t, srv, "edit_file", map[string]any{
"path": "notes.md",
"old_string": "changed",
"new_string": "world",
})
assert.False(t, result.IsError, "missing base_sha keeps legacy unconditional-write semantics")
resp := decodeFileOpsResult(t, result)
assert.Equal(t, "applied", resp["status"])
// new_sha is still returned on success even when base_sha was not passed,
// so the caller can pipeline the next edit with the fresh SHA.
assert.NotEmpty(t, resp["new_sha"])
}
func TestEditFile_BaseSHA_DryRunReturnsNewSHA(t *testing.T) {
srv, dir := setupTestServer(t)
target := filepath.Join(dir, "preview.md")
require.NoError(t, os.WriteFile(target, []byte("alpha\n"), 0o644))
sha := fileBlobSHA(t, target)
result := callTool(t, srv, "edit_file", map[string]any{
"path": "preview.md",
"old_string": "alpha",
"new_string": "beta",
"base_sha": sha,
"dry_run": true,
})
assert.False(t, result.IsError)
resp := decodeFileOpsResult(t, result)
assert.Equal(t, "would_apply", resp["status"])
require.NotEmpty(t, resp["new_sha"])
// File is untouched by dry-run.
got, err := os.ReadFile(target)
require.NoError(t, err)
assert.Equal(t, "alpha\n", string(got))
}
// --- write_file ------------------------------------------------------------
func TestWriteFile_BaseSHA_Matches_Overwrites(t *testing.T) {
srv, dir := setupTestServer(t)
target := filepath.Join(dir, "doc.txt")
require.NoError(t, os.WriteFile(target, []byte("old"), 0o644))
sha := fileBlobSHA(t, target)
result := callTool(t, srv, "write_file", map[string]any{
"path": "doc.txt",
"content": "new",
"base_sha": sha,
})
assert.False(t, result.IsError)
resp := decodeFileOpsResult(t, result)
assert.Equal(t, "overwritten", resp["status"])
require.NotEmpty(t, resp["new_sha"])
got, err := os.ReadFile(target)
require.NoError(t, err)
assert.Equal(t, "new", string(got))
assert.Equal(t, gitBlobSHA(got), resp["new_sha"])
}
func TestWriteFile_BaseSHA_Stale_RejectsOverwrite(t *testing.T) {
srv, dir := setupTestServer(t)
target := filepath.Join(dir, "doc.txt")
require.NoError(t, os.WriteFile(target, []byte("v1"), 0o644))
staleSHA := fileBlobSHA(t, target)
// Out-of-band edit between the agent's read and the agent's write.
require.NoError(t, os.WriteFile(target, []byte("v2-human"), 0o644))
result := callTool(t, srv, "write_file", map[string]any{
"path": "doc.txt",
"content": "v2-agent-clobber",
"base_sha": staleSHA,
})
assert.True(t, result.IsError, "stale base_sha must reject overwrite")
assert.Contains(t, errorText(t, result), "base_sha mismatch")
got, err := os.ReadFile(target)
require.NoError(t, err)
assert.Equal(t, "v2-human", string(got), "human edit must survive")
}
func TestWriteFile_BaseSHA_OnMissingFile_IsDrift(t *testing.T) {
srv, _ := setupTestServer(t)
// Caller observed a SHA on a file that the human has since deleted.
// Treating this as "create" would silently resurrect the file in a
// shape the caller never confirmed; refuse instead.
result := callTool(t, srv, "write_file", map[string]any{
"path": "gone.txt",
"content": "resurrected",
"base_sha": "0123456789abcdef0123456789abcdef01234567",
})
assert.True(t, result.IsError)
assert.Contains(t, errorText(t, result), "base_sha mismatch")
}
func TestWriteFile_NoBaseSHA_BackwardCompatible(t *testing.T) {
srv, dir := setupTestServer(t)
target := filepath.Join(dir, "doc.txt")
require.NoError(t, os.WriteFile(target, []byte("old"), 0o644))
// Mutate out of band.
require.NoError(t, os.WriteFile(target, []byte("human"), 0o644))
result := callTool(t, srv, "write_file", map[string]any{
"path": "doc.txt",
"content": "new",
})
assert.False(t, result.IsError, "no base_sha keeps legacy unconditional-overwrite semantics")
resp := decodeFileOpsResult(t, result)
assert.Equal(t, "overwritten", resp["status"])
require.NotEmpty(t, resp["new_sha"])
}
func TestWriteFile_BaseSHA_NewFileCreation(t *testing.T) {
// Creating a brand-new file requires base_sha to be empty —
// passing one would imply the file already existed at that SHA.
srv, dir := setupTestServer(t)
result := callTool(t, srv, "write_file", map[string]any{
"path": "fresh.txt",
"content": "hello",
})
assert.False(t, result.IsError)
resp := decodeFileOpsResult(t, result)
assert.Equal(t, "created", resp["status"])
require.NotEmpty(t, resp["new_sha"])
got, err := os.ReadFile(filepath.Join(dir, "fresh.txt"))
require.NoError(t, err)
assert.Equal(t, gitBlobSHA(got), resp["new_sha"])
}
// --- edit_symbol -----------------------------------------------------------
func TestEditSymbol_BaseSHA_Matches_Applies(t *testing.T) {
srv, dir := setupTestServer(t)
mainPath := filepath.Join(dir, "main.go")
sha := fileBlobSHA(t, mainPath)
// setupTestServer indexes a `helper` function at the bottom of main.go.
// Edit its body — empty `{}` → `{ _ = 1 }` — to exercise the drift path
// on a real symbol the indexer wired up.
result := callTool(t, srv, "edit_symbol", map[string]any{
"id": "main.go::helper",
"old_source": "func helper() {}",
"new_source": "func helper() { _ = 1 }",
"base_sha": sha,
})
assert.False(t, result.IsError, "matching base_sha must not block the symbol edit")
resp := decodeFileOpsResult(t, result)
assert.Equal(t, "applied", resp["status"])
require.NotEmpty(t, resp["new_sha"])
// new_sha must equal the SHA of the file as it now sits on disk.
assert.Equal(t, fileBlobSHA(t, mainPath), resp["new_sha"])
}
func TestEditSymbol_BaseSHA_Stale_RejectsWriteAndPreservesFile(t *testing.T) {
srv, dir := setupTestServer(t)
mainPath := filepath.Join(dir, "main.go")
originalBytes, err := os.ReadFile(mainPath)
require.NoError(t, err)
staleSHA := gitBlobSHA(originalBytes)
// Out-of-band edit between read and write.
humanEdited := append([]byte("// human edit\n"), originalBytes...)
require.NoError(t, os.WriteFile(mainPath, humanEdited, 0o644))
result := callTool(t, srv, "edit_symbol", map[string]any{
"id": "main.go::helper",
"old_source": "func helper() {}",
"new_source": "func helper() { _ = 1 }",
"base_sha": staleSHA,
})
assert.True(t, result.IsError, "stale base_sha must reject the symbol edit")
assert.Contains(t, errorText(t, result), "base_sha mismatch")
got, err := os.ReadFile(mainPath)
require.NoError(t, err)
assert.Equal(t, string(humanEdited), string(got), "human edit must survive on the disk")
}
func TestEditSymbol_NoBaseSHA_BackwardCompatible(t *testing.T) {
// Legacy behaviour: without base_sha the call is unconditional.
// We confirm that an edit on an unchanged file still applies and
// that new_sha rides on the success response so callers can
// pipeline the next edit even when they did not opt into the
// drift guard.
srv, dir := setupTestServer(t)
mainPath := filepath.Join(dir, "main.go")
result := callTool(t, srv, "edit_symbol", map[string]any{
"id": "main.go::helper",
"old_source": "func helper() {}",
"new_source": "func helper() { _ = 1 }",
})
assert.False(t, result.IsError, "no base_sha keeps legacy semantics")
resp := decodeFileOpsResult(t, result)
assert.Equal(t, "applied", resp["status"])
require.NotEmpty(t, resp["new_sha"])
// new_sha must equal the SHA of the file as it now sits on disk —
// the contract is identical whether or not base_sha was supplied.
assert.Equal(t, fileBlobSHA(t, mainPath), resp["new_sha"])
}