fix(merge gate): an unknown head commit never merges; pr_merge refuses closed and draft PRs (review of PR #76)
Review found a bypass: a PR reporting an empty head sha made the runs query lose its head_sha filter (the repo's 50 most recent runs of any commit were classified) and dropped head_commit_id (omitempty), so the merge was unbound. Both tools now go through evaluateMergeGate, which refuses without a head sha, and pollRuns makes no query without one. pr_merge also reports merged:false for a PR that is not open or is a draft (Gitea refuses closed ones itself; the gate no longer depends on that). Tests: unit (gate, counting-server pollRuns with positive control), end to end for both tools, CI scoped to head_sha, closed/merged/draft. The two caller-wiring mutants are equivalent to the pollRuns guard and survive by design. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
This commit is contained in:
@@ -60,7 +60,7 @@ func shipFakeFull(t *testing.T, runsJSON string, requiredApprovals, mergeStatus
|
||||
_, _ = w.Write([]byte(`{"content":{"path":"x","sha":"s"},"commit":{"sha":"c"}}`))
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(p, "/pulls"):
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"number":7,"title":"t","html_url":"http://x/pulls/7","state":"open","head":{"ref":"tbd/x","sha":"abc"},"base":{"ref":"main"}}`))
|
||||
_, _ = w.Write([]byte(`{"number":7,"title":"t","html_url":"http://x/pulls/7","state":"open","head":{"ref":"tbd/x","sha":"` + shipPRHeadSha + `"},"base":{"ref":"main"}}`))
|
||||
case r.Method == http.MethodGet && strings.Contains(p, "/actions/runs"):
|
||||
_, _ = w.Write([]byte(`{"total_count":0,"workflow_runs":` + runsJSON + `}`))
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(p, "/pulls/7/merge"):
|
||||
@@ -219,9 +219,27 @@ func TestTBDShip_Resume_ExistingBranchAndPR(t *testing.T) {
|
||||
assert.True(t, merged.Load())
|
||||
}
|
||||
|
||||
// shipPRHeadSha is the head sha the fake reports for the PR it opens (tests may blank it, then restore it).
|
||||
var shipPRHeadSha = "abc"
|
||||
|
||||
// shipLastMergeBody is the body of the most recent merge request the fake received.
|
||||
var shipLastMergeBody atomic.Value
|
||||
|
||||
// An empty head sha used to unscope the CI query and drop the merge binding (review of PR #76). Fail closed.
|
||||
func TestTBDShip_AnEmptyHeadShaFailsClosed(t *testing.T) {
|
||||
shipPRHeadSha = ""
|
||||
t.Cleanup(func() { shipPRHeadSha = "abc" })
|
||||
var merged, deleted atomic.Bool
|
||||
srv := shipFake(t, shipGreen, &merged, &deleted)
|
||||
defer srv.Close()
|
||||
|
||||
res := callShip(t, srv.URL, shipArgs)
|
||||
|
||||
assert.Equal(t, false, res["merged"])
|
||||
assert.Contains(t, res["reason"], "head commit")
|
||||
assert.False(t, merged.Load(), "an unknown head commit must never merge")
|
||||
}
|
||||
|
||||
// TOCTOU: the merge is bound to the head commit whose CI the gate evaluated (gitea-mcp#74).
|
||||
func TestTBDShip_TheMergeIsBoundToTheCommitTheGateChecked(t *testing.T) {
|
||||
var merged, deleted atomic.Bool
|
||||
|
||||
Reference in New Issue
Block a user