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.
173 lines
7.2 KiB
Go
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
|
|
}
|