- scripts/check-phase19.7.sh: fail-closed multi-repo gate (self-test, go, marker; plugins, consumers, docker, admin and docs are stubs until the later tasks fill them) - modules/pier/helpers_test.go: the plan 02 fixtures moved out of the smokes plus fakeGuard, fakeVerifier, fakeLinkResolver, fakePersonalResolver, routePlugin, newApp, twoInstances, sdkClient and fireSubjectChanged - TestToolsList (pier), TestDispatchMarker (bouncer, cabana with a Postgres part through the screens harness, which now keeps its app)
145 lines
5.2 KiB
Go
145 lines
5.2 KiB
Go
package pier_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// Smoke tests of phase 19.7 plan 02 Task 3: Redis sessions shared by two
|
|
// pier instances on one miniredis (rehydration, binding, DELETE, idle TTL,
|
|
// the per-binding cap) and the pub/sub fan-out of subject events.
|
|
|
|
func TestPierSessionsSmoke(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
mr := miniredis.RunT(t)
|
|
cfg := "enabled: false\nsessions:\n store: redis\n idle_ttl: 30m\n max_per_binding: 2\n"
|
|
a := newInstance(t, mr, cfg, &acmeGuard{})
|
|
b := newInstance(t, mr, cfg, &acmeGuard{})
|
|
|
|
// Initialise on A, call on B: B rehydrates the session from Redis.
|
|
sid := openSession(t, ctx, a, "oas_at_test")
|
|
call := rpc(t, ctx, b.srv.URL+"/mcp", "oas_at_test", sid, callBody)
|
|
if call.status != http.StatusOK || !strings.Contains(call.body, `\"principal\":7`) || strings.Contains(call.body, `"isError":true`) {
|
|
t.Fatalf("call on B = %d %s", call.status, call.body)
|
|
}
|
|
keys := mr.Keys()
|
|
if !containsPrefix(keys, "acme:mcp:s:") || !containsPrefix(keys, "acme:mcp:b:oauth:c1:7") {
|
|
t.Fatalf("redis keys = %v, want acme:mcp:s:<id> and acme:mcp:b:oauth:c1:7", keys)
|
|
}
|
|
|
|
// Another binding presenting the same id is refused.
|
|
other := rpc(t, ctx, b.srv.URL+"/mcp", "oas_at_other", sid, callBody)
|
|
if other.status != http.StatusForbidden || !strings.Contains(other.body, "session_binding") {
|
|
t.Fatalf("other binding = %d %s", other.status, other.body)
|
|
}
|
|
|
|
// DELETE on B ends the session everywhere: A answers 404 next.
|
|
del, err := http.NewRequestWithContext(ctx, http.MethodDelete, b.srv.URL+"/mcp", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
del.Header.Set("Authorization", "Bearer oas_at_test")
|
|
del.Header.Set("Mcp-Session-Id", sid)
|
|
resp, err := http.DefaultClient.Do(del)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("DELETE on B = %d", resp.StatusCode)
|
|
}
|
|
if gone := rpc(t, ctx, a.srv.URL+"/mcp", "oas_at_test", sid, callBody); gone.status != http.StatusNotFound {
|
|
t.Fatalf("call on A after DELETE = %d %s", gone.status, gone.body)
|
|
}
|
|
|
|
// An idle session expires: after the TTL the id is unknown.
|
|
expiring := openSession(t, ctx, a, "oas_at_test")
|
|
mr.FastForward(31 * time.Minute)
|
|
if expired := rpc(t, ctx, a.srv.URL+"/mcp", "oas_at_test", expiring, callBody); expired.status != http.StatusNotFound {
|
|
t.Fatalf("call after the idle TTL = %d %s", expired.status, expired.body)
|
|
}
|
|
|
|
// max_per_binding + 1 initialisations answer 429.
|
|
openSession(t, ctx, a, "oas_at_test")
|
|
openSession(t, ctx, b, "oas_at_test")
|
|
third := rpc(t, ctx, a.srv.URL+"/mcp", "oas_at_test", "", initializeBody)
|
|
if third.status != http.StatusTooManyRequests || !strings.Contains(third.body, "too_many_sessions") {
|
|
t.Fatalf("third session = %d %s", third.status, third.body)
|
|
}
|
|
}
|
|
|
|
func TestPierFanOutSmoke(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
mr := miniredis.RunT(t)
|
|
guard := &acmeGuard{}
|
|
cfg := "enabled: false\nsessions:\n store: redis\n"
|
|
a := newInstance(t, mr, cfg, guard)
|
|
b := newInstance(t, mr, cfg, guard)
|
|
|
|
changed := make(chan struct{}, 4)
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "acme-test", Version: "1"}, &mcp.ClientOptions{
|
|
ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {
|
|
select {
|
|
case changed <- struct{}{}:
|
|
default:
|
|
}
|
|
},
|
|
})
|
|
transport := &mcp.StreamableClientTransport{
|
|
Endpoint: b.srv.URL + "/mcp",
|
|
HTTPClient: &http.Client{Transport: bearerTransport{token: "oas_at_test"}},
|
|
}
|
|
cs, err := client.Connect(ctx, transport, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer cs.Close()
|
|
list, err := cs.ListTools(ctx, nil)
|
|
if err != nil || len(list.Tools) != 1 {
|
|
t.Fatalf("tools/list = %+v, %v", list, err)
|
|
}
|
|
|
|
// The guard now denies the tool; a permissions event fired on A reaches
|
|
// the session on B as notifications/tools/list_changed.
|
|
guard.deny.Store(true)
|
|
if err := a.app.Events.Fire(ctx, &pact.MCPSubjectChanged{Guard: "acme", UserID: 7, Kind: pact.MCPSubjectPermissions}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-changed:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("no tools/list_changed within 10 s")
|
|
}
|
|
list, err = cs.ListTools(ctx, nil)
|
|
if err != nil || len(list.Tools) != 0 {
|
|
t.Fatalf("tools/list after the permission change = %+v, %v", list, err)
|
|
}
|
|
|
|
// A revocation event ends the session: the next call is refused.
|
|
if err := a.app.Events.Fire(ctx, &pact.MCPSubjectChanged{Guard: "acme", UserID: 7, Kind: pact.MCPSubjectRevoked}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for {
|
|
if _, err := cs.ListTools(ctx, nil); err != nil {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the session survived the revocation event")
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
if raw := rpc(t, ctx, b.srv.URL+"/mcp", "oas_at_test", cs.ID(), callBody); raw.status != http.StatusNotFound {
|
|
t.Fatalf("call after revocation = %d %s", raw.status, raw.body)
|
|
}
|
|
}
|