PR #66 review: json.Decoder.Token() fails on a number outside float64 range (1e999), which json.Unmarshal ignores in an unknown key, so a decoy key ended the scan and the #65 repro wrote to main. The decoder now uses UseNumber, and any scan error is a validation error. The duplicate check folds with strings.EqualFold (as encoding/json does) instead of ToLower, so long s and the Kelvin sign are caught in nested objects too. Refs #65 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
5.2 KiB
Go
117 lines
5.2 KiB
Go
package tools_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// countingServer fails nothing by itself; it records how many requests reached
|
|
// it so a test can assert that a rejected call never touched Gitea.
|
|
func countingServer(t *testing.T) (*httptest.Server, *atomic.Int32) {
|
|
t.Helper()
|
|
var n atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
n.Add(1)
|
|
w.WriteHeader(http.StatusTeapot)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv, &n
|
|
}
|
|
|
|
// #65 (1): encoding/json matches keys case-insensitively and the last one wins,
|
|
// so a client-side check reading `branch` could see a different value from the
|
|
// one the server acts on. Any two keys equal under strings.EqualFold, and any
|
|
// key that differs from a schema field only in case, are rejected before a
|
|
// request is made.
|
|
func TestParseArgsRejectsCaseVariantKeys(t *testing.T) {
|
|
cases := map[string]string{
|
|
"issue repro: branch + Branch": `{"owner":"probe","repo":"t","name":"t","path":"case8.txt","content":"x","message":"m","branch":"feat/y8","Branch":"main"}`,
|
|
"exact duplicate key": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","branch":"main"}`,
|
|
"case-only schema mismatch": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","BRANCH":"main"}`,
|
|
"nested object duplicate": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","extra":{"a":1,"A":2}}`,
|
|
// PR #66 review: a number the token scanner cannot parse as float64 must
|
|
// not make the scan give up and let the duplicate through.
|
|
"1e999 decoy before duplicate": `{"owner":"probe","repo":"t","name":"t","path":"case8.txt","content":"x","message":"m","z":1e999,"branch":"feat/y8","Branch":"main"}`,
|
|
"-1e309 decoy before exact dup": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","z":-1e309,"branch":"feat/y8","branch":"main"}`,
|
|
// encoding/json folds like strings.EqualFold: long s (U+017F) == s.
|
|
"nested long-s duplicate": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","extra":{"s":1,"\u017f":2}}`,
|
|
}
|
|
for name, raw := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
srv, n := countingServer(t)
|
|
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
|
|
_, err := tool.Call(context.Background(), json.RawMessage(raw))
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
|
assert.Zero(t, n.Load(), "no request may reach gitea")
|
|
})
|
|
}
|
|
}
|
|
|
|
// #65 (2): `refs/heads/main` as a branch makes Gitea create
|
|
// refs/heads/refs/heads/main, after which every API write to main 500s.
|
|
// Branch names that can create such refs are rejected before any request.
|
|
func TestBranchNameValidationRejectsBeforeRequest(t *testing.T) {
|
|
bad := []string{
|
|
"refs/heads/main", "refs/tags/v1", "a..b", "feat x", "feat\tx", "feat/",
|
|
"/feat", "-feat", ".feat", "feat//x", "feat/.x", "feat.lock", "feat.", "HEAD", "feat@{1}", "",
|
|
}
|
|
for _, b := range bad {
|
|
t.Run("file_write_branch "+b, func(t *testing.T) {
|
|
srv, n := countingServer(t)
|
|
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
|
|
args, _ := json.Marshal(map[string]string{
|
|
"owner": "probe", "repo": "t", "path": "p", "content": "x", "message": "m", "branch": b,
|
|
})
|
|
_, err := tool.Call(context.Background(), args)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
|
assert.Zero(t, n.Load(), "no request may reach gitea")
|
|
})
|
|
if b == "" {
|
|
continue // tbd_ship derives a branch when empty
|
|
}
|
|
t.Run("tbd_ship "+b, func(t *testing.T) {
|
|
srv, n := countingServer(t)
|
|
tool := tools.NewTBDShip(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
|
|
args, _ := json.Marshal(map[string]string{
|
|
"owner": "probe", "repo": "t", "path": "p", "content": "x", "message": "m", "branch": b,
|
|
})
|
|
_, err := tool.Call(context.Background(), args)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
|
assert.Zero(t, n.Load(), "no request may reach gitea")
|
|
})
|
|
}
|
|
}
|
|
|
|
// tbd_ship's base is the branch-protection probe key: `refs/heads/main` would
|
|
// read as unprotected, so it is held to the same rule as the head branch.
|
|
func TestTBDShipRejectsRefsPrefixedBase(t *testing.T) {
|
|
srv, n := countingServer(t)
|
|
tool := tools.NewTBDShip(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
|
|
_, err := tool.Call(context.Background(), json.RawMessage(
|
|
`{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","base":"refs/heads/main"}`))
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
|
assert.Zero(t, n.Load(), "no request may reach gitea")
|
|
}
|
|
|
|
func TestBranchNameValidationAcceptsOrdinaryNames(t *testing.T) {
|
|
for _, b := range []string{"main", "feat/y8", "tbd/fix-abc-1234abcd", "hold/mail-chat-infra-21", "v1.2_x"} {
|
|
t.Run(b, func(t *testing.T) {
|
|
assert.NoError(t, tools.ValidateBranchName(b))
|
|
})
|
|
}
|
|
}
|