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