- 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)
174 lines
6.1 KiB
Go
174 lines
6.1 KiB
Go
package pier_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"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 closes
|
|
// the evicted server's listen 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 closes the evicted server's 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)
|
|
}
|
|
listNames(t, ctx, sdkClient(t, ctx, in.url(), fmt.Sprintf("oas_at_lru_%d", capacity-1), clientOpts{version: "newest"}))
|
|
waitEnded(t, ended, 5*time.Second, "LRU eviction")
|
|
if n := in.pier().StatelessServers(); n != capacity {
|
|
t.Fatalf("stateless servers after the eviction = %d, want %d", n, capacity)
|
|
}
|
|
})
|
|
}
|