- pier: TestSessionReserve (A-WR-03), TestLockScope (A-WR-07), TestScopeWidening (A-WR-04), TestStatelessFanOut (A-WR-05, A-WR-06), TestPersonalTokenBinding (A-WR-09), TestDispatchHardening (A-WR-01, A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-08), TestCatalogPathFields (A-WR-02, A-IN-07), TestPromptWriteCeiling (C-IN-01), TestTokenScopedRevoke (B-WR-04, C-IN-02), TestResolveClientAddress (B-IN-04); test seam WrapStore - pact TestFrontendTokenScopes, towel TestClientIP, cabana TestTokenScopeNameParity - cabana TestAccountContributionURL (B-WR-03), TestAccountContributionsIsolation (B-WR-06, B-IN-03), TestRecordActionPromptLocalized (B-IN-02), and the Postgres TestAccountTokenRevokeEvent (B-WR-04) and TestResolveBackendTokenRemote (B-IN-04) - vitest: hotkeysEditing.test.ts (B-WR-01, B-WR-02) and the extended AccountContributions.test.ts (B-WR-03, B-WR-06, B-IN-01) - check-phase19.7.sh requires every name (GO_TESTS, GO_TAGGED_TESTS, DOCKER_TESTS, ADMIN_FILES) and scans the new files (PHASE_TEST_FILES)
208 lines
8.2 KiB
Go
208 lines
8.2 KiB
Go
package pier_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// dispatchGuard is a fakeGuard whose MCPDispatch answer is given per
|
|
// route method.
|
|
type dispatchGuard struct {
|
|
*fakeGuard
|
|
byMethod func(pact.MCPSubject, string) (pact.MCPDispatch, error)
|
|
}
|
|
|
|
func (g dispatchGuard) MCPDispatch(_ context.Context, s pact.MCPSubject, method string) (pact.MCPDispatch, error) {
|
|
return g.byMethod(s, method)
|
|
}
|
|
|
|
type oneSegmentInput struct {
|
|
ID string `json:"id" path:"id"`
|
|
}
|
|
|
|
type manySegmentsInput struct {
|
|
Rest string `json:"rest" path:"rest"`
|
|
}
|
|
|
|
type numberInput struct {
|
|
ID int64 `json:"id" path:"id"`
|
|
N int64 `json:"n,omitempty" query:"n"`
|
|
}
|
|
|
|
// TestDispatchHardening pins plan 06's dispatch hardening (A-WR-01, the
|
|
// runtime half of A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-08): path values
|
|
// the mux never delivers (., .., empty segments, a slash in a single
|
|
// segment) are refused before the route; a missing path value is refused;
|
|
// a provider error answers a fixed text and its message reaches only the
|
|
// log; a large integer keeps its digits in the path and the query; the
|
|
// inner request carries the outer Host and forwarding headers but never the
|
|
// outer credentials; and a provider whose answer contradicts the tool's
|
|
// guard is refused.
|
|
func TestDispatchHardening(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
var hits atomic.Int64
|
|
seen := func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": r.PathValue("id"),
|
|
"rest": r.PathValue("rest"),
|
|
"n": r.URL.Query().Get("n"),
|
|
"host": r.Host,
|
|
"proto": r.Header.Get("X-Forwarded-Proto"),
|
|
"fhost": r.Header.Get("X-Forwarded-Host"),
|
|
"auth": r.Header.Get("Authorization"),
|
|
"cookie": r.Header.Get("Cookie"),
|
|
})
|
|
}
|
|
hard := routePlugin{id: "test.hard",
|
|
routes: []testRoute{
|
|
{method: http.MethodPost, path: "/hard/one/{id}", mw: []string{"acme.auth"}, h: seen},
|
|
{method: http.MethodGet, path: "/hard/many/{rest...}", mw: []string{"acme.auth"}, h: seen},
|
|
{method: http.MethodGet, path: "/hard/num/{id}", mw: []string{"acme.auth"}, h: seen},
|
|
},
|
|
tools: []pact.MCPTool{
|
|
{Name: "one", Method: "POST", Path: "/hard/one/{id}", Input: oneSegmentInput{}},
|
|
{Name: "many", Method: "GET", Path: "/hard/many/{rest...}", Input: manySegmentsInput{}},
|
|
{Name: "num", Method: "GET", Path: "/hard/num/{id}", Input: numberInput{}},
|
|
}}
|
|
// One frontend guard provider (an OAuth grant acts under exactly one);
|
|
// its dispatch answer depends on the route's method: PUT fails like a
|
|
// database error, PATCH names the backend guard, DELETE answers a
|
|
// backend principal.
|
|
pgxLike := "ERROR: relation \"backend_users\" does not exist (SQLSTATE 42P01)"
|
|
guard := dispatchGuard{fakeGuard: &fakeGuard{}}
|
|
guard.byMethod = func(s pact.MCPSubject, method string) (pact.MCPDispatch, error) {
|
|
switch method {
|
|
case http.MethodPut:
|
|
return pact.MCPDispatch{}, errors.New(pgxLike)
|
|
case http.MethodPatch:
|
|
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID}, Guards: []string{"backend"}}, nil
|
|
case http.MethodDelete:
|
|
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID, Backend: true}, Guards: []string{"acme"}}, nil
|
|
}
|
|
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID}, Guards: []string{"acme"}}, nil
|
|
}
|
|
hard.routes = append(hard.routes,
|
|
testRoute{method: http.MethodPut, path: "/hard/broken", mw: []string{"acme.auth"}, h: seen},
|
|
testRoute{method: http.MethodPatch, path: "/hard/claims-backend", mw: []string{"acme.auth"}, h: seen},
|
|
testRoute{method: http.MethodDelete, path: "/hard/backend-principal", mw: []string{"acme.auth"}, h: seen},
|
|
)
|
|
hard.tools = append(hard.tools,
|
|
pact.MCPTool{Name: "broken", Method: "PUT", Path: "/hard/broken", Input: struct{}{}},
|
|
pact.MCPTool{Name: "claims_backend", Method: "PATCH", Path: "/hard/claims-backend", Input: struct{}{}},
|
|
pact.MCPTool{Name: "backend_principal", Method: "DELETE", Path: "/hard/backend-principal", Input: struct{}{}},
|
|
)
|
|
app := newApp(t, appSpec{guards: []pact.MCPGuard{guard}})
|
|
in := startInstance(t, app, hard)
|
|
t.Cleanup(in.pier().StopReaper)
|
|
outer, err := url.Parse(in.srv.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
logs := captureLogs(t)
|
|
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{headers: map[string]string{
|
|
"Cookie": "summer_session=outer",
|
|
"X-Forwarded-Proto": "https",
|
|
"X-Forwarded-Host": "site.example",
|
|
}})
|
|
call := func(name string, args map[string]any) *mcp.CallToolResult {
|
|
t.Helper()
|
|
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args})
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
return res
|
|
}
|
|
refused := func(t *testing.T, name string, args map[string]any, want string) {
|
|
t.Helper()
|
|
before := hits.Load()
|
|
res := call(name, args)
|
|
if !res.IsError || !strings.Contains(resultText(t, res), want) {
|
|
t.Fatalf("%s %v = %+v, want a tool error with %q", name, args, res, want)
|
|
}
|
|
if hits.Load() != before {
|
|
t.Fatalf("%s %v reached the route", name, args)
|
|
}
|
|
}
|
|
|
|
t.Run("path values the mux never delivers are refused", func(t *testing.T) {
|
|
for _, v := range []string{"..", "."} {
|
|
refused(t, "hard_one", map[string]any{"id": v}, "must not contain empty, . or .. segments")
|
|
}
|
|
refused(t, "hard_one", map[string]any{"id": ""}, "one non-empty path segment")
|
|
refused(t, "hard_one", map[string]any{"id": "a/b"}, "one non-empty path segment")
|
|
for _, v := range []string{"a/../b", "a//b", "./a", "a/.."} {
|
|
refused(t, "hard_many", map[string]any{"rest": v}, "must not contain empty, . or .. segments")
|
|
}
|
|
if res := call("hard_many", map[string]any{"rest": "a/b"}); res.IsError || structured(t, res)["rest"] != "a/b" {
|
|
t.Fatalf("a/b on a {rest...} route = %+v", res)
|
|
}
|
|
})
|
|
|
|
t.Run("a missing path value is refused", func(t *testing.T) {
|
|
refused(t, "hard_one", map[string]any{}, "invalid arguments")
|
|
})
|
|
|
|
t.Run("a provider error answers the fixed text and is only logged", func(t *testing.T) {
|
|
before := hits.Load()
|
|
res := call("hard_broken", nil)
|
|
if !res.IsError || resultText(t, res) != "pier: the tool could not be run for this caller" {
|
|
t.Fatalf("provider error = %+v %q", res, resultText(t, res))
|
|
}
|
|
if strings.Contains(resultText(t, res), "SQLSTATE") {
|
|
t.Fatal("the provider's message reached the agent")
|
|
}
|
|
if hits.Load() != before {
|
|
t.Fatal("a failed dispatch reached the route")
|
|
}
|
|
if !strings.Contains(logs.String(), "42P01") || !strings.Contains(logs.String(), "guard dispatch failed") {
|
|
t.Fatalf("the provider's message is not in the log: %s", logs.String())
|
|
}
|
|
})
|
|
|
|
t.Run("a large integer keeps its digits in the path and the query", func(t *testing.T) {
|
|
res := cs.CallTool
|
|
raw := json.RawMessage(`{"id":9007199254740993,"n":9007199254740993}`)
|
|
out, err := res(ctx, &mcp.CallToolParams{Name: "hard_num", Arguments: raw})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := structured(t, out)
|
|
if out.IsError || got["id"] != "9007199254740993" || got["n"] != "9007199254740993" {
|
|
t.Fatalf("large integer = %+v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("the inner request has the outer Host and forwarding headers, never credentials", func(t *testing.T) {
|
|
got := structured(t, call("hard_num", map[string]any{"id": 5}))
|
|
if got["host"] != outer.Host || got["proto"] != "https" || got["fhost"] != "site.example" {
|
|
t.Fatalf("inner request host %v proto %v forwarded host %v, want %s https site.example", got["host"], got["proto"], got["fhost"], outer.Host)
|
|
}
|
|
if got["auth"] != "" || got["cookie"] != "" {
|
|
t.Fatalf("outer credentials reached the route: auth %q cookie %q", got["auth"], got["cookie"])
|
|
}
|
|
})
|
|
|
|
t.Run("a dispatch contradicting the tool's guard is refused", func(t *testing.T) {
|
|
for _, name := range []string{"hard_claims_backend", "hard_backend_principal"} {
|
|
refused(t, name, nil, "pier: the tool could not be run for this caller")
|
|
}
|
|
if !strings.Contains(logs.String(), "contradicts the tool's guard") {
|
|
t.Fatalf("no contradiction line: %s", logs.String())
|
|
}
|
|
})
|
|
}
|