- 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
113 lines
4.4 KiB
Go
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")
|
|
})
|
|
}
|
|
}
|