mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
bee399bc94
A `--field github_pr=...` / MCP `field` setter stored the PR as a string on every transport, so no link rendered, and `=null` stored the string "null". BUG-2627 had exempted github_pr from the reserved-key refusal as remote MCP's only writer; that writer never worked. ItemUpdate gains a typed `github_pr` PR object (number > 0 and an absolute http(s) url required) plus `clear_github_pr`, lowered by the handler into the field patch after the caller's own fields_patch is refused every reserved key. `pad github link` / `unlink` / `project reconcile` use it and verify from the response that the change landed (an older server ignores the member and answers 200). Not exposed on the MCP catalog; a remote PR-link action is deferred to the catalog-trim decision. The point is validity, not sender identity. Create (still accepts hand-written reserved keys) is BUG-3163. Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
94 lines
3.3 KiB
Go
94 lines
3.3 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// BUG-2696: github_pr is written only through the typed, validated update
|
|
// member. A fields_patch entry (every `--field` / MCP `field` setter lowers
|
|
// into one) is refused like the other three reserved keys.
|
|
|
|
func githubPRItem(t *testing.T) (*Server, string) {
|
|
t.Helper()
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
item := createItem(t, srv, slug, "tasks", map[string]interface{}{"title": "Linkable"})
|
|
return srv, "/api/v1/workspaces/" + slug + "/items/" + item.Slug
|
|
}
|
|
|
|
func getCodeContext(t *testing.T, srv *Server, path string) *models.ItemCodeContext {
|
|
t.Helper()
|
|
rr := doRequest(srv, "GET", path, nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("get: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var item models.Item
|
|
parseJSON(t, rr, &item)
|
|
return item.CodeContext
|
|
}
|
|
|
|
func TestGitHubPRFieldPatchIsRefusedAndNamesTheWriter(t *testing.T) {
|
|
t.Parallel()
|
|
srv, path := githubPRItem(t)
|
|
for _, v := range []any{
|
|
`{"number":7,"url":"https://github.com/o/r/pull/7"}`, // what --field sends: a string
|
|
map[string]any{"number": 7, "url": "https://github.com/o/r/pull/7"},
|
|
nil,
|
|
} {
|
|
rr := doRequest(srv, "PATCH", path, map[string]any{"fields_patch": map[string]any{"github_pr": v}})
|
|
if rr.Code != http.StatusBadRequest || !strings.Contains(rr.Body.String(), "pad github link") {
|
|
t.Fatalf("fields_patch github_pr=%v: want 400 naming `pad github link`, got %d %s", v, rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
if cc := getCodeContext(t, srv, path); cc != nil {
|
|
t.Fatalf("a refused write left a code context: %+v", cc)
|
|
}
|
|
}
|
|
|
|
func TestGitHubPRTypedMemberWritesAReadableLinkAndClears(t *testing.T) {
|
|
t.Parallel()
|
|
srv, path := githubPRItem(t)
|
|
|
|
rr := doRequest(srv, "PATCH", path, map[string]any{"github_pr": map[string]any{
|
|
"number": 7, "url": "https://github.com/o/r/pull/7", "state": "OPEN", "branch": "b",
|
|
}})
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("typed write: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
cc := getCodeContext(t, srv, path)
|
|
if cc == nil || cc.PullRequest == nil || cc.PullRequest.Number != 7 || cc.Branch != "b" {
|
|
t.Fatalf("the typed write did not render a link: %+v", cc)
|
|
}
|
|
|
|
rr = doRequest(srv, "PATCH", path, map[string]any{"clear_github_pr": true})
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("clear: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
if cc := getCodeContext(t, srv, path); cc != nil {
|
|
t.Fatalf("clear_github_pr left a link: %+v", cc)
|
|
}
|
|
}
|
|
|
|
func TestGitHubPRTypedMemberIsValidated(t *testing.T) {
|
|
t.Parallel()
|
|
srv, path := githubPRItem(t)
|
|
cases := map[string]map[string]any{
|
|
"no number": {"github_pr": map[string]any{"url": "https://github.com/o/r/pull/7"}},
|
|
"relative url": {"github_pr": map[string]any{"number": 7, "url": "/o/r/pull/7"}},
|
|
"with its clear": {"github_pr": map[string]any{"number": 7, "url": "https://x/p/7"}, "clear_github_pr": true},
|
|
"with full fields": {"github_pr": map[string]any{"number": 7, "url": "https://x/p/7"}, "fields": `{"status":"open"}`},
|
|
}
|
|
for name, body := range cases {
|
|
if rr := doRequest(srv, "PATCH", path, body); rr.Code != http.StatusBadRequest {
|
|
t.Errorf("%s: want 400, got %d %s", name, rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
if cc := getCodeContext(t, srv, path); cc != nil {
|
|
t.Fatalf("a refused typed write left a link: %+v", cc)
|
|
}
|
|
}
|