Files
summercms/modules/pier/sessions_test.go
Jakub Zych f14eae96dd test(19.7-05): framework unit tests for every validation row and the gate's admin stage
- pier: TestCatalog, TestDispatch, TestGuardRules, TestSessions (Redis
  across two instances and the memory store), TestFanOut; the store clock
  and stateless-server seams in export_test.go
- surf: TestStreamingRoute (throttle, CORS profile and access-log pattern on
  a stream route) and TestPierMount in both builds; the streaming smoke
  restores the mount hook it replaces
- wristband TestMCPScopes; cabana TestAccountContributions (Postgres part:
  transaction and unchanged My account payloads), TestRecordActionPrompt,
  TestScopeLimitForMethods; TestRecordActionPromptRun pins the empty Value
  of an action without a prompt
- vitest: 403 and failed-load cases for My account contributions, the
  cancelled prompt
- gate: every name required (tagged runs included), account.go unchanged
  since 51d088a, admin stage live
2026-10-23 14:04:16 +02:00

113 lines
4.4 KiB
Go

package pier_test
import (
"context"
"net/http"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
)
// deleteSession sends DELETE for a session with a bearer.
func deleteSession(t *testing.T, ctx context.Context, url, token, sid string) int {
t.Helper()
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Mcp-Session-Id", sid)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
return resp.StatusCode
}
// sessionCase is one store under test: the instance that opens sessions,
// the one that serves them next and the store's clock.
type sessionCase struct {
name string
a, b *instance
advance func(time.Duration)
}
// TestSessions (D-25): on the Redis store shared by two instances and on
// the memory store of one instance, a session is bound to the identity that
// opened it (another binding is 403 on POST and DELETE), is served by
// whichever instance receives it (rehydrated from the stored protocol state,
// never from a stored identity: the call runs as the request's bearer),
// DELETE ends it everywhere (404 next), an idle session expires after
// mcp.sessions.idle_ttl (404, and the client initialises a new one) and
// mcp.sessions.max_per_binding caps a binding's open sessions (429).
func TestSessions(t *testing.T) {
cfgTail := " idle_ttl: 30m\n max_per_binding: 2\n"
plugin := acmePlugin{tools: []pact.MCPTool{echoTool()}}
var cases []sessionCase
{
a, b, mr := twoInstances(t, appSpec{mcp: "enabled: false\nsessions:\n store: redis\n" + cfgTail, guards: []pact.MCPGuard{&acmeGuard{}}}, plugin)
cases = append(cases, sessionCase{"redis across two instances", a, b, mr.FastForward})
}
{
one := startInstance(t, newApp(t, appSpec{mcp: "enabled: false\nsessions:\n store: memory\n" + cfgTail, guards: []pact.MCPGuard{&acmeGuard{}}}), plugin)
cases = append(cases, sessionCase{"memory on one instance", one, one, func(d time.Duration) {
if !one.pier().AdvanceStoreClock(d) {
t.Fatal("the memory instance has no memory store")
}
}})
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
sid := openSession(t, ctx, c.a, "oas_at_test")
call := rpc(t, ctx, c.b.url(), "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 the second instance = %d %s", call.status, call.body)
}
if other := rpc(t, ctx, c.b.url(), "oas_at_other", sid, callBody); other.status != http.StatusForbidden || !strings.Contains(other.body, "session_binding") {
t.Fatalf("another binding = %d %s", other.status, other.body)
}
if code := deleteSession(t, ctx, c.b.url(), "oas_at_other", sid); code != http.StatusForbidden {
t.Fatalf("DELETE by another binding = %d", code)
}
if code := deleteSession(t, ctx, c.b.url(), "oas_at_test", sid); code != http.StatusNoContent {
t.Fatalf("DELETE = %d", code)
}
for _, in := range []*instance{c.a, c.b} {
if gone := rpc(t, ctx, in.url(), "oas_at_test", sid, callBody); gone.status != http.StatusNotFound {
t.Fatalf("call after DELETE = %d %s", gone.status, gone.body)
}
}
if code := deleteSession(t, ctx, c.a.url(), "oas_at_test", sid); code != http.StatusNotFound {
t.Fatalf("second DELETE = %d", code)
}
idle := openSession(t, ctx, c.a, "oas_at_test")
c.advance(31 * time.Minute)
if expired := rpc(t, ctx, c.b.url(), "oas_at_test", idle, callBody); expired.status != http.StatusNotFound {
t.Fatalf("call after the idle TTL = %d %s", expired.status, expired.body)
}
fresh := openSession(t, ctx, c.b, "oas_at_test")
if again := rpc(t, ctx, c.a.url(), "oas_at_test", fresh, callBody); again.status != http.StatusOK {
t.Fatalf("call on the re-initialised session = %d %s", again.status, again.body)
}
openSession(t, ctx, c.a, "oas_at_test")
third := rpc(t, ctx, c.b.url(), "oas_at_test", "", initializeBody)
if third.status != http.StatusTooManyRequests || !strings.Contains(third.body, "too_many_sessions") {
t.Fatalf("session over max_per_binding = %d %s", third.status, third.body)
}
// Another binding has its own allowance.
openSession(t, ctx, c.b, "oas_at_other")
})
}
}