Files
pad/internal/mcp/dispatch_http_github_pr_test.go
T
xarmian bee399bc94 fix(items): github_pr is written only through a typed, validated update member; the field-setter door refuses it (BUG-2696, ToolSurfaceVersion 0.47) (#1469)
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
2026-09-23 14:35:41 -04:00

63 lines
2.2 KiB
Go

package mcp
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-2696: a remote MCP `field: ["github_pr=..."]` setter used to store the
// PR as a string (no link rendered) and `github_pr=null` stored "null". It is
// now refused, naming the CLI writer, and nothing is stored.
func TestDispatch_ItemUpdate_RefusesAGithubPRFieldSetter(t *testing.T) {
srv, st := newPadServer(t)
wsRec := doJSONReq(t, srv, http.MethodPost, "/api/v1/workspaces", map[string]any{"name": "GP"})
var ws models.Workspace
_ = json.Unmarshal(wsRec.Body.Bytes(), &ws)
owner, err := st.CreateUser(models.UserCreate{Email: "gp@example.com", Name: "GP", Password: "x"})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ws.ID, owner.ID, "owner"); err != nil {
t.Fatal(err)
}
d := &HTTPHandlerDispatcher{Handler: srv, UserResolver: fixedUserResolver(owner)}
res, err := d.Dispatch(WithDispatchInput(context.Background(), map[string]any{
"workspace": ws.Slug, "collection": "tasks", "title": "T",
}), []string{"item", "create"}, nil)
if err != nil || res.IsError {
t.Fatalf("create: %v %#v", err, res)
}
ref := res.StructuredContent.(map[string]any)["ref"].(string)
for _, v := range []string{
`github_pr={"number":7,"url":"https://github.com/o/r/pull/7"}`,
`github_pr=null`,
} {
up, err := d.Dispatch(WithDispatchInput(context.Background(), map[string]any{
"workspace": ws.Slug, "ref": ref, "field": []any{v},
}), []string{"item", "update"}, nil)
if err != nil {
t.Fatal(err)
}
if !up.IsError {
t.Fatalf("%s: the field setter was accepted: %#v", v, up.StructuredContent)
}
raw, _ := json.Marshal(up.Content)
if !strings.Contains(string(raw), "pad github link") {
t.Errorf("%s: the refusal must name the writer: %s", v, raw)
}
}
show, _ := d.Dispatch(WithDispatchInput(context.Background(), map[string]any{"workspace": ws.Slug, "ref": ref}), []string{"item", "show"}, nil)
fields := itemFieldsAsMap(t, show.StructuredContent.(map[string]any))
if _, stored := fields["github_pr"]; stored {
t.Fatalf("github_pr was stored despite the refusal: %v", fields["github_pr"])
}
}