Files
summercms/modules/pier/stateless_fanout_test.go
Jakub Zych afc7510d1b fix(19.7): RA-IN-02 stream bearer re-checks carry the client address
The stateful GET stream and the stateless subscriptions/listen stream
capture the client address when they open and resolve every periodic
re-check with towel.WithClientIP, as the bearer middleware does, so a
failing re-check (a revoked admin token, for example) is logged with the
remote address. TestStreamRecheckClientIP records the address the verifier
sees on every verification of both streams.
2026-10-23 20:19:22 +02:00

244 lines
8.7 KiB
Go

package pier_test
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// listenWatch is a client transport that adds the bearer and closes ended
// when the response body of the client's subscriptions/listen request ends
// (the server closed the stream).
type listenWatch struct {
token string
once sync.Once
ended chan struct{}
}
func (l *listenWatch) RoundTrip(r *http.Request) (*http.Response, error) {
r = r.Clone(r.Context())
r.Header.Set("Authorization", "Bearer "+l.token)
listen := false
if r.Body != nil {
raw, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
_ = r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(raw))
listen = strings.Contains(string(raw), `"subscriptions/listen"`)
}
resp, err := http.DefaultTransport.RoundTrip(r)
if err != nil || !listen {
return resp, err
}
resp.Body = &watchedBody{ReadCloser: resp.Body, done: func() { l.once.Do(func() { close(l.ended) }) }}
return resp, nil
}
type watchedBody struct {
io.ReadCloser
done func()
}
func (b *watchedBody) Read(p []byte) (int, error) {
n, err := b.ReadCloser.Read(p)
if err != nil {
b.done()
}
return n, err
}
// listenClient connects a newest-protocol client with a list_changed
// handler, so it opens a subscriptions/listen stream, and answers the
// channel closed when that stream ends.
func listenClient(t *testing.T, ctx context.Context, url, token string) (*mcp.ClientSession, <-chan struct{}) {
t.Helper()
watch := &listenWatch{token: token, ended: make(chan struct{})}
client := mcp.NewClient(&mcp.Implementation{Name: "listen-test", Version: "1"}, &mcp.ClientOptions{
ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {},
})
cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: url, HTTPClient: &http.Client{Transport: watch}}, nil)
if err != nil {
t.Fatalf("connect with %s: %v", token, err)
}
t.Cleanup(func() { _ = cs.Close() })
if cs.ID() != "" {
t.Fatalf("the newest client got a session %q", cs.ID())
}
return cs, watch.ended
}
func waitEnded(t *testing.T, ended <-chan struct{}, within time.Duration, what string) {
t.Helper()
select {
case <-ended:
case <-time.After(within):
t.Fatalf("%s: the listen stream is still open after %s", what, within)
}
}
// TestStatelessFanOut pins plan 06's stateless lifecycle (A-WR-05,
// A-WR-06): an instance that served only stateless requests receives a
// revocation published by the other instance through Redis and evicts its
// entry; a subscriptions/listen stream ends within the re-check interval
// once its bearer stops resolving; and an LRU eviction at capacity spares a
// server with an open listen stream (RA-WR-04), evicting the least recently
// used idle one, while a revocation still ends that stream.
func TestStatelessFanOut(t *testing.T) {
plugin := acmePlugin{tools: []pact.MCPTool{echoTool(), showTool()}}
t.Run("a stateless-only instance applies a revocation from the other instance", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
a, b, _ := twoInstances(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}}, plugin)
t.Cleanup(a.pier().StopReaper)
t.Cleanup(b.pier().StopReaper)
cs := sdkClient(t, ctx, b.url(), "oas_at_test", clientOpts{version: "newest"})
listNames(t, ctx, cs)
if n := b.pier().StatelessServers(); n != 1 {
t.Fatalf("B stateless servers = %d, want 1", n)
}
fireSubjectChanged(t, a.app, "acme", 7, pact.MCPSubjectRevoked)
deadline := time.Now().Add(10 * time.Second)
for b.pier().StatelessServers() != 0 {
if time.Now().After(deadline) {
t.Fatal("B never evicted its stateless server: the revocation did not reach it")
}
time.Sleep(20 * time.Millisecond)
}
})
t.Run("a listen stream ends once its bearer stops resolving", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
verifier := newFakeVerifier(map[string]pact.MCPGrant{"oas_at_listen": grant(12, "c7", 12, "read", "write")})
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}, verifier: verifier}), plugin)
t.Cleanup(in.pier().StopReaper)
in.pier().SetRecheck(50 * time.Millisecond)
logs := captureLogs(t)
_, ended := listenClient(t, ctx, in.url(), "oas_at_listen")
select {
case <-ended:
t.Fatal("the listen stream ended while the bearer resolved")
case <-time.After(300 * time.Millisecond):
}
verifier.revoke("oas_at_listen")
waitEnded(t, ended, 3*time.Second, "revoked bearer")
if !strings.Contains(logs.String(), "step=stream_closed") {
t.Fatalf("no stream_closed line: %s", logs.String())
}
})
t.Run("an LRU eviction spares a server with a listen stream", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
const capacity = 16 // max_per_binding 1 times the LRU factor
extra := map[string]pact.MCPGrant{"oas_at_first": grant(20, "c20", 20, "read")}
for i := 0; i < capacity; i++ {
extra[fmt.Sprintf("oas_at_lru_%d", i)] = grant(uint(100+i), fmt.Sprintf("lru%d", i), uint(100+i), "read")
}
in := startInstance(t, newApp(t, appSpec{
mcp: "enabled: false\nsessions:\n max_per_binding: 1\n",
guards: []pact.MCPGuard{&acmeGuard{}},
verifier: newFakeVerifier(extra),
}), plugin)
t.Cleanup(in.pier().StopReaper)
_, ended := listenClient(t, ctx, in.url(), "oas_at_first")
for i := 0; i < capacity-1; i++ {
listNames(t, ctx, sdkClient(t, ctx, in.url(), fmt.Sprintf("oas_at_lru_%d", i), clientOpts{version: "newest"}))
}
select {
case <-ended:
t.Fatal("the listen stream ended below capacity")
case <-time.After(200 * time.Millisecond):
}
if n := in.pier().StatelessServers(); n != capacity {
t.Fatalf("stateless servers at capacity = %d, want %d", n, capacity)
}
// One binding past capacity: the listener's server is the least
// recently used, but it is serving the stream, so the oldest idle
// server goes instead.
listNames(t, ctx, sdkClient(t, ctx, in.url(), fmt.Sprintf("oas_at_lru_%d", capacity-1), clientOpts{version: "newest"}))
select {
case <-ended:
t.Fatal("the LRU eviction ended a live listen stream")
case <-time.After(300 * time.Millisecond):
}
if n := in.pier().StatelessServers(); n != capacity {
t.Fatalf("stateless servers after the eviction = %d, want %d", n, capacity)
}
// The spared entry is still where a revocation looks for it.
fireSubjectChanged(t, in.app, "acme", 20, pact.MCPSubjectRevoked)
waitEnded(t, ended, 5*time.Second, "revocation after the eviction")
})
}
// ipVerifier is a fakeVerifier that records the client address on the
// context of every verification.
type ipVerifier struct {
*fakeVerifier
mu sync.Mutex
ips []string
}
func (v *ipVerifier) MCPVerifyToken(ctx context.Context, raw string) (pact.MCPGrant, error) {
ip, _ := towel.ClientIP(ctx)
v.mu.Lock()
v.ips = append(v.ips, ip)
v.mu.Unlock()
return v.fakeVerifier.MCPVerifyToken(ctx, raw)
}
func (v *ipVerifier) seen() []string {
v.mu.Lock()
defer v.mu.Unlock()
return append([]string(nil), v.ips...)
}
// TestStreamRecheckClientIP pins RA-IN-02: the periodic bearer re-check of
// a stateless listen stream and of a stateful GET stream resolves with the
// stream's client address on the context, as the bearer middleware does,
// so a failing re-check is logged with it.
func TestStreamRecheckClientIP(t *testing.T) {
plugin := acmePlugin{tools: []pact.MCPTool{echoTool()}}
for _, path := range []struct{ name, version string }{{"stateless listen", "newest"}, {"stateful GET", ""}} {
t.Run(path.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
verifier := &ipVerifier{fakeVerifier: newFakeVerifier(nil)}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}, verifier: verifier}), plugin)
t.Cleanup(in.pier().StopReaper)
in.pier().SetRecheck(30 * time.Millisecond)
if path.version == "newest" {
listenClient(t, ctx, in.url(), "oas_at_test")
} else {
sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{changed: make(chan struct{}, 1)})
}
before := len(verifier.seen())
deadline := time.Now().Add(5 * time.Second)
for len(verifier.seen()) < before+3 {
if time.Now().After(deadline) {
t.Fatalf("no stream re-checks within 5 s (%d verifications)", len(verifier.seen()))
}
time.Sleep(20 * time.Millisecond)
}
for i, ip := range verifier.seen() {
if ip == "" {
t.Fatalf("verification %d ran without a client address: %q", i, verifier.seen())
}
}
})
}
}