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