Files
summercms/modules/pier/token_events_test.go
Jakub Zych ccf77b9103 test(19.7-09): named tests for every framework and admin SPA fix of plans 06 and 07
- pier: TestSessionReserve (A-WR-03), TestLockScope (A-WR-07),
  TestScopeWidening (A-WR-04), TestStatelessFanOut (A-WR-05, A-WR-06),
  TestPersonalTokenBinding (A-WR-09), TestDispatchHardening (A-WR-01,
  A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-08), TestCatalogPathFields
  (A-WR-02, A-IN-07), TestPromptWriteCeiling (C-IN-01),
  TestTokenScopedRevoke (B-WR-04, C-IN-02), TestResolveClientAddress
  (B-IN-04); test seam WrapStore
- pact TestFrontendTokenScopes, towel TestClientIP, cabana
  TestTokenScopeNameParity
- cabana TestAccountContributionURL (B-WR-03), TestAccountContributionsIsolation
  (B-WR-06, B-IN-03), TestRecordActionPromptLocalized (B-IN-02), and the
  Postgres TestAccountTokenRevokeEvent (B-WR-04) and
  TestResolveBackendTokenRemote (B-IN-04)
- vitest: hotkeysEditing.test.ts (B-WR-01, B-WR-02) and the extended
  AccountContributions.test.ts (B-WR-03, B-WR-06, B-IN-01)
- check-phase19.7.sh requires every name (GO_TESTS, GO_TAGGED_TESTS,
  DOCKER_TESTS, ADMIN_FILES) and scans the new files (PHASE_TEST_FILES)
2026-10-23 18:36:16 +02:00

175 lines
6.0 KiB
Go

package pier_test
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/pier"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/towel"
)
// TestTokenScopedRevoke pins plan 07's narrowed revocation (B-WR-04, the
// framework half of C-IN-02, P-31) across two instances on one Redis
// store: a revoked event fired on one instance with a TokenID closes, on
// the other, only the session opened with that personal token (another
// token's session and an OAuth session of the same user, even one whose
// grant carries the same token id, stay); with a ClientID only that
// client's OAuth sessions close; with neither every session of the user
// closes.
func TestTokenScopedRevoke(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
personal := fakePersonalResolver{tokens: map[string]pact.MCPSubject{
"usr_first": {Guard: "acme", UserID: 40, TokenID: 601, Scopes: []string{"read", "write"}},
"usr_second": {Guard: "acme", UserID: 40, TokenID: 602, Scopes: []string{"read", "write"}},
}}
verifier := newFakeVerifier(map[string]pact.MCPGrant{
// The same token id as usr_first: a TokenID event must not reach it.
"oas_at_u40_ca": grant(40, "ca", 601, "read", "write"),
"oas_at_u40_cb": grant(40, "cb", 702, "read", "write"),
})
a, b, _ := twoInstances(t, appSpec{
mcp: "enabled: false\nallow_personal_tokens: true\nsessions:\n store: redis\n",
guards: []pact.MCPGuard{&acmeGuard{}},
verifier: verifier,
personal: personal,
}, acmePlugin{tools: []pact.MCPTool{echoTool()}})
t.Cleanup(a.pier().StopReaper)
t.Cleanup(b.pier().StopReaper)
sessions := map[string]string{}
for _, tok := range []string{"usr_first", "usr_second", "oas_at_u40_ca", "oas_at_u40_cb"} {
sessions[tok] = openSession(t, ctx, b, tok)
}
status := func(tok string) int {
return rpc(t, ctx, b.url(), tok, sessions[tok], callBody).status
}
waitClosed := func(tok string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for status(tok) != http.StatusNotFound {
if time.Now().After(deadline) {
t.Fatalf("the session of %s survived the event", tok)
}
time.Sleep(20 * time.Millisecond)
}
}
alive := func(toks ...string) {
t.Helper()
for _, tok := range toks {
if got := status(tok); got != http.StatusOK {
t.Fatalf("the session of %s = %d after an event that does not name it", tok, got)
}
}
}
fire := func(ev *pact.MCPSubjectChanged) {
t.Helper()
ev.Guard, ev.UserID, ev.Kind = "acme", 40, pact.MCPSubjectRevoked
if err := a.app.Events.Fire(context.Background(), ev); err != nil {
t.Fatal(err)
}
}
t.Run("a TokenID closes only that personal token's session", func(t *testing.T) {
fire(&pact.MCPSubjectChanged{TokenID: 601})
waitClosed("usr_first")
alive("usr_second", "oas_at_u40_ca", "oas_at_u40_cb")
})
t.Run("a ClientID closes only that client's OAuth sessions", func(t *testing.T) {
fire(&pact.MCPSubjectChanged{ClientID: "ca"})
waitClosed("oas_at_u40_ca")
alive("usr_second", "oas_at_u40_cb")
})
t.Run("neither closes every session of the user", func(t *testing.T) {
fire(&pact.MCPSubjectChanged{})
waitClosed("usr_second")
waitClosed("oas_at_u40_cb")
})
}
// addressVerifier is the fake verifier that records the client address it
// finds on the resolve context.
type addressVerifier struct {
*fakeVerifier
mu sync.Mutex
seen []string
}
func (v *addressVerifier) MCPVerifyToken(ctx context.Context, raw string) (pact.MCPGrant, error) {
ip, _ := towel.ClientIP(ctx)
v.mu.Lock()
v.seen = append(v.seen, ip)
v.mu.Unlock()
return v.fakeVerifier.MCPVerifyToken(ctx, raw)
}
func (v *addressVerifier) last() string {
v.mu.Lock()
defer v.mu.Unlock()
if len(v.seen) == 0 {
return ""
}
return v.seen[len(v.seen)-1]
}
// proxyHost is a test host that also answers the client address the way
// surf does behind a trusted proxy (here: the X-Forwarded-For header).
type proxyHost struct {
*testHost
}
func (proxyHost) ClientIP(r *http.Request) string { return r.Header.Get("X-Forwarded-For") }
// TestResolveClientAddress pins plan 07's client address on the resolve
// context (B-IN-04, P-32): a token resolver sees towel.ClientIP equal to
// the host part of RemoteAddr, or, through a host that implements
// ClientIP (surf's trusted-proxy answer), to that host's answer.
func TestResolveClientAddress(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
plugins := []party.Plugin{acmePlugin{tools: []pact.MCPTool{echoTool()}}}
t.Run("RemoteAddr host", func(t *testing.T) {
verifier := &addressVerifier{fakeVerifier: newFakeVerifier(nil)}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}, verifier: verifier}), plugins...)
t.Cleanup(in.pier().StopReaper)
openSession(t, ctx, in, "oas_at_test")
if got := verifier.last(); got != "127.0.0.1" {
t.Fatalf("resolver saw client address %q, want 127.0.0.1", got)
}
})
t.Run("a host answering ClientIP", func(t *testing.T) {
verifier := &addressVerifier{fakeVerifier: newFakeVerifier(nil)}
app := newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}, verifier: verifier})
router, err := surf.BuildRouter(app, plugins)
if err != nil {
t.Fatal(err)
}
if err := app.Config.Set("mcp.enabled", true); err != nil {
t.Fatal(err)
}
host := proxyHost{testHost: &testHost{r: router, handlers: map[string]http.Handler{}}}
if err := pier.Mount(host, app, plugins); err != nil {
t.Fatal(err)
}
if p, ok := app.Lookup[*pier.Pier](); ok {
t.Cleanup(p.StopReaper)
}
srv := httptest.NewServer(host)
t.Cleanup(srv.Close)
cs := sdkClient(t, ctx, srv.URL+"/mcp", "oas_at_test", clientOpts{headers: map[string]string{"X-Forwarded-For": "203.0.113.7"}})
listNames(t, ctx, cs)
if got := verifier.last(); got != "203.0.113.7" {
t.Fatalf("resolver saw client address %q, want the host's answer 203.0.113.7", got)
}
})
}