- 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)
175 lines
6.0 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|