Files
summercms/modules/pier/sessions_smoke_test.go
Jakub Zych 3bbbe6c11e test(19.7-05): phase gate with the go and marker stages, pier test helpers, TestToolsList and TestDispatchMarker
- 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)
2026-10-23 13:31:41 +02:00

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)
}
}