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
63 lines
2.2 KiB
Go
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"])
|
|
}
|
|
}
|