Files
sm-user-plugin/mcp_guard_test.go
Jakub Zych a1b78b80f5 fix: the MCP dispatch credential carries the personal token id
MCPUserGuard.MCPDispatch now sets the in-memory credential's ID to the
subject's TokenID when the subject came from a personal usr_ token (no
ClientID), so a handler running under MCP dispatch can record which
token made a change. An OAuth subject's TokenID names the OAuth
server's token row, so its credential keeps ID 0. The credential is
still never saved.
2026-10-28 11:30:44 +01:00

173 lines
7.2 KiB
Go

package user
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestUserMCPGuard (D-15, D-16, D-35) extends TestUserMCPGuardSmoke: the
// requirement parser refuses a user.* middleware it does not know and a
// scope or permission middleware without its name; a soft-deleted user is
// neither allowed nor dispatched and its personal tokens stop resolving, as
// do expired ones; and the in-memory credential MCPDispatch builds passes
// the route's own user.scope:read middleware when the subject was granted
// read and is refused (403) when it was not, through the plugin's real
// route chain in-process; that credential carries a personal token's id
// (the subject's TokenID) and keeps ID 0 for an OAuth subject. Skips under
// -short.
func TestUserMCPGuard(t *testing.T) {
env := newAdminEnv(t)
ctx := context.Background()
guards, _ := env.app.Lookup[*pact.MCPGuards]()
guard, ok := guards.Lookup("user")
if !ok {
t.Fatal("no user guard")
}
resolver, _ := env.app.Lookup[pact.MCPPersonalTokenResolver]()
t.Run("requirement parser refusals", func(t *testing.T) {
for _, mw := range [][]string{{"user.api_token", "user.unknown"}, {"user.api_token", "user.scope:"}, {"user.api_token", "user.permission:"}} {
if _, err := guard.MCPRequirements(mw); err == nil {
t.Fatalf("%v accepted", mw)
}
}
if req, err := guard.MCPRequirements([]string{"throttle:user-api", "user.api_token", "user.scope:read", "user.scope:read"}); err != nil || len(req.Scopes) != 1 {
t.Fatalf("duplicate scope = %+v, %v", req, err)
}
})
t.Run("soft-deleted user", func(t *testing.T) {
user := env.seedUser(t, "mcp-gone", nil)
secret, _ := env.mintUserToken(t, user, "read")
subject := pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: []string{"read"}}
if ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{}, nil); err != nil || !ok {
t.Fatalf("before deletion = %v, %v", ok, err)
}
if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, user.ID).Error; err != nil {
t.Fatal(err)
}
if ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{}, nil); err != nil || ok {
t.Fatalf("after deletion Allows = %v, %v", ok, err)
}
if _, err := guard.MCPDispatch(ctx, subject, http.MethodGet); err == nil {
t.Fatal("a deleted user was dispatched")
}
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, secret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {
t.Fatalf("a deleted user's token = %v, %v", matched, err)
}
})
t.Run("expired token", func(t *testing.T) {
user := env.seedUser(t, "mcp-expired", nil)
secret, token := env.mintUserToken(t, user, "read")
if err := env.gdb.Model(&models.APIToken{}).Where("id = ?", token.ID).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
t.Fatal(err)
}
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, secret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {
t.Fatalf("an expired token = %v, %v", matched, err)
}
})
t.Run("dispatch credential against user.scope", func(t *testing.T) {
user := env.seedUser(t, "mcp-scope", nil)
me := func(scopes ...string) *httptest.ResponseRecorder {
t.Helper()
d, err := guard.MCPDispatch(ctx, pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: scopes}, http.MethodGet)
if err != nil {
t.Fatal(err)
}
principal, _ := d.Principal.(*bouncer.Principal)
req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/token/me", nil)
req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: d.Guards, Principal: principal, Credential: d.Credential}))
rec := httptest.NewRecorder()
env.h.ServeHTTP(rec, req)
return rec
}
if rec := me("read"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"email":"`+user.Email+`"`) {
t.Fatalf("read subject = %d %s", rec.Code, rec.Body.String())
}
if rec := me("write"); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Missing required scope: read") {
t.Fatalf("write-only subject = %d %s", rec.Code, rec.Body.String())
}
if _, isToken := mustDispatchCredential(t, guard, user.ID).(*models.APIToken); !isToken {
t.Fatal("the dispatch credential is not an in-memory API token")
}
})
t.Run("dispatch credential carries the personal token id", func(t *testing.T) {
user := env.seedUser(t, "mcp-token-id", nil)
secret, minted := env.mintUserToken(t, user, "read")
subject, matched, err := resolver.MCPResolvePersonalToken(ctx, secret)
if !matched || err != nil {
t.Fatalf("resolve = %v, %v", matched, err)
}
if subject.ClientID != "" || subject.TokenID != minted.ID {
t.Fatalf("subject = %+v, want TokenID %d and no ClientID", subject, minted.ID)
}
d, err := guard.MCPDispatch(ctx, subject, http.MethodGet)
if err != nil {
t.Fatal(err)
}
tok, isToken := d.Credential.(*models.APIToken)
if !isToken {
t.Fatalf("credential = %+v", d.Credential)
}
if tok.ID != minted.ID || tok.UserID != user.ID || !tok.HasScope("read") || tok.HasScope("write") {
t.Fatalf("credential = %+v, want ID %d, UserID %d, scopes [read]", tok, minted.ID, user.ID)
}
principal, _ := d.Principal.(*bouncer.Principal)
req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/token/me", nil)
req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: d.Guards, Principal: principal, Credential: d.Credential}))
rec := httptest.NewRecorder()
env.h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"email":"`+user.Email+`"`) {
t.Fatalf("token/me = %d %s", rec.Code, rec.Body.String())
}
var rows int64
if err := env.gdb.Model(&models.APIToken{}).Where("user_id = ?", user.ID).Count(&rows).Error; err != nil {
t.Fatal(err)
}
if rows != 1 {
t.Fatalf("user_api_tokens rows = %d, want 1 (the credential was saved)", rows)
}
})
t.Run("dispatch credential of an OAuth subject has no id", func(t *testing.T) {
user := env.seedUser(t, "mcp-oauth-id", nil)
_, minted := env.mintUserToken(t, user, "read")
subject := pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: []string{"read"}, ClientID: "oauth-client-" + adminStamp(), TokenID: minted.ID}
d, err := guard.MCPDispatch(ctx, subject, http.MethodGet)
if err != nil {
t.Fatal(err)
}
tok, isToken := d.Credential.(*models.APIToken)
if !isToken || tok.ID != 0 || tok.UserID != user.ID || !tok.HasScope("read") {
t.Fatalf("credential = %+v, want ID 0, UserID %d, scopes [read]", d.Credential, user.ID)
}
})
}
// mustDispatchCredential returns the credential MCPDispatch builds for a
// read subject without a TokenID, which therefore has ID 0 and is not
// saved.
func mustDispatchCredential(t *testing.T, guard pact.MCPGuard, userID uint) any {
t.Helper()
d, err := guard.MCPDispatch(context.Background(), pact.MCPSubject{Guard: "user", UserID: userID, Scopes: []string{"read"}}, http.MethodGet)
if err != nil {
t.Fatal(err)
}
if tok, ok := d.Credential.(*models.APIToken); !ok || tok.ID != 0 {
t.Fatalf("credential = %+v", d.Credential)
}
return d.Credential
}