Files
sm-user-plugin/mcp_guard_smoke_test.go
Jakub Zych dbb3d33b48 fix: user groups delete keeps FormAfterDelete's declaration; members' subject events fire from FormBeforeDelete
The member ids are read in the delete's transaction before the memberships
go, and the permissions events fire after it commits, as before; the
smoke now covers a group delete.
2026-10-23 12:16:53 +02:00

192 lines
8.6 KiB
Go

package user
import (
"context"
"errors"
"net/http"
"slices"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
)
// mintUserToken stores a personal API token of the user with the scopes and
// returns its secret and row.
func (e *adminEnv) mintUserToken(t *testing.T, user *models.User, scopes ...string) (string, *models.APIToken) {
t.Helper()
secret, token, err := classes.MintAPIToken(classes.DefaultAPITokenPrefix, user.ID, "mcp "+adminStamp(), scopes, nil, nil)
if err != nil {
t.Fatal(err)
}
if err := e.gdb.Create(token).Error; err != nil {
t.Fatal(err)
}
return secret, token
}
// grantGroup stores the codes as the group's permissions.
func (e *adminEnv) grantGroup(t *testing.T, group *models.UserGroup, codes ...string) {
t.Helper()
parts := make([]string, 0, len(codes))
for _, code := range codes {
parts = append(parts, strconv.Quote(code)+":1")
}
if err := e.gdb.Exec(`UPDATE user_groups SET permissions = ? WHERE id = ?`, "{"+strings.Join(parts, ",")+"}", group.ID).Error; err != nil {
t.Fatal(err)
}
}
// TestUserMCPGuardSmoke: the "user" guard provider is registered and
// published as the personal token resolver; it reads the plugin's route
// middleware, allows a tool with exactly the checks the routes run (scopes
// from the session, permission codes from the user's groups, every code
// required), dispatches with the group grants and an in-memory token
// credential, and resolves usr_ tokens. GET /token/me, the user_me target,
// answers the user payload to a read token only, and revoking a token fires
// the revoked subject event.
func TestUserMCPGuardSmoke(t *testing.T) {
env := newAdminEnv(t)
spy := env.spySubjects()
ctx := context.Background()
guards, ok := env.app.Lookup[*pact.MCPGuards]()
if !ok || guards == nil {
t.Fatal("*pact.MCPGuards is not published")
}
guard, ok := guards.Lookup("user")
if !ok || guard.MCPGuardName() != "user" {
t.Fatalf("user guard = %v %v", guard, ok)
}
resolver, ok := env.app.Lookup[pact.MCPPersonalTokenResolver]()
if !ok || resolver == nil {
t.Fatal("pact.MCPPersonalTokenResolver is not published")
}
if !guard.MCPTokenGuarded([]string{"throttle:user-api", "user.api_token", "user.scope:read"}) || guard.MCPTokenGuarded([]string{"throttle:user-api", "jwt.auth"}) {
t.Fatal("MCPTokenGuarded must answer true only with user.api_token")
}
req, err := guard.MCPRequirements([]string{"throttle:user-api", "user.api_token", "user.scope:read", "user.scope:write", "user.permission:acme.posts.edit"})
if err != nil || !slices.Equal(req.Scopes, []string{"read", "write"}) || !slices.Equal(req.Permissions, []string{"acme.posts.edit"}) {
t.Fatalf("MCPRequirements = %+v %v", req, err)
}
if _, err := guard.MCPRequirements([]string{"user.api_token", "user.bogus"}); err == nil {
t.Fatal("an unknown user.* middleware must be an error")
}
stamp := adminStamp()
codeA, codeB, codeOwn := "mcp."+stamp+".a", "mcp."+stamp+".b", "mcp."+stamp+".own"
user := env.seedUser(t, "mcp", func(u *models.User) { u.Permissions = models.PermissionSet{codeOwn: 1} })
groupA, groupB := env.seedGroup(t, "MCP A "+stamp, "mcp-a-"+stamp), env.seedGroup(t, "MCP B "+stamp, "mcp-b-"+stamp)
env.grantGroup(t, groupA, codeA)
env.grantGroup(t, groupB, codeB)
env.join(t, user, groupA, groupB)
subject := pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: []string{"read"}}
allows := func(req pact.MCPRequirements, perms ...string) bool {
t.Helper()
ok, err := guard.MCPAllows(ctx, subject, req, perms)
if err != nil {
t.Fatal(err)
}
return ok
}
if allows(pact.MCPRequirements{Scopes: []string{"read", "write"}}) {
t.Fatal("a scope the session lacks must refuse")
}
if !allows(pact.MCPRequirements{Scopes: []string{"read"}, Permissions: []string{codeA}}, codeB) {
t.Fatal("codes granted by the user's groups must allow")
}
if allows(pact.MCPRequirements{Scopes: []string{"read"}, Permissions: []string{codeA, "mcp." + stamp + ".missing"}}) {
t.Fatal("every code is required: one missing code must refuse")
}
if allows(pact.MCPRequirements{Scopes: []string{"read"}}, codeOwn) {
t.Fatal("a user-level permission alone must not grant (the route checks group grants)")
}
if ok, err := guard.MCPAllows(ctx, pact.MCPSubject{Guard: "user", UserID: user.ID + 1_000_000, Scopes: []string{"read"}}, pact.MCPRequirements{}, nil); err != nil || ok {
t.Fatalf("an unknown user = %v %v", ok, err)
}
dispatch, err := guard.MCPDispatch(ctx, subject, http.MethodGet)
if err != nil {
t.Fatal(err)
}
principal, ok := dispatch.Principal.(*bouncer.Principal)
if !ok || principal.ID != user.ID || !principal.PermissionGrants[codeA] || !principal.PermissionGrants[codeB] || principal.PermissionGrants[codeOwn] {
t.Fatalf("dispatch principal = %+v", dispatch.Principal)
}
credential, ok := dispatch.Credential.(*models.APIToken)
if !ok || credential.ID != 0 || credential.UserID != user.ID || !credential.HasScope("read") || credential.HasScope("write") {
t.Fatalf("dispatch credential = %+v", dispatch.Credential)
}
if !slices.Equal(dispatch.Guards, []string{"api_token"}) {
t.Fatalf("dispatch guards = %v", dispatch.Guards)
}
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, "oas_at_x"); matched || err != nil {
t.Fatalf("a foreign prefix = %v %v", matched, err)
}
readSecret, readToken := env.mintUserToken(t, user, "read", "ai")
got, matched, err := resolver.MCPResolvePersonalToken(ctx, readSecret)
if err != nil || !matched || got.Guard != "user" || got.UserID != user.ID || got.TokenID != readToken.ID || !slices.Equal(got.Scopes, []string{"read", "ai"}) {
t.Fatalf("resolve = %+v %v %v", got, matched, err)
}
var stamped models.APIToken
if err := env.gdb.First(&stamped, readToken.ID).Error; err != nil || stamped.LastUsedAt == nil {
t.Fatalf("last_used_at not stamped: %+v %v", stamped, err)
}
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, classes.DefaultAPITokenPrefix+"not-a-token"); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {
t.Fatalf("an unknown usr_ token = %v %v", matched, err)
}
// GET /token/me: the user_me target.
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/token/me", readSecret, nil)
if !strings.Contains(rec.Body.String(), `"email":"`+user.Email+`"`) || !strings.HasPrefix(rec.Body.String(), `{"user":`) {
t.Fatalf("token/me = %s", rec.Body.String())
}
writeSecret, _ := env.mintUserToken(t, user, "write")
rec = env.expect(t, http.StatusForbidden, http.MethodGet, "/_user/api/v1/token/me", writeSecret, nil)
if !strings.Contains(rec.Body.String(), "Missing required scope: read") {
t.Fatalf("token/me with write = %s", rec.Body.String())
}
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/token/me", freshUserToken(t, user), nil)
// Revoking the token through the API fires revoked; the resolver then
// refuses it.
spy.reset()
env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/tokens/"+strconv.FormatUint(uint64(readToken.ID), 10), freshUserToken(t, user), nil)
if !spy.has("user", user.ID, pact.MCPSubjectRevoked) {
t.Fatalf("revoke fired %+v", spy.events)
}
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, readSecret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {
t.Fatalf("a revoked token = %v %v", matched, err)
}
// Saving a group's permissions on the admin screen fires permissions
// for each member after the save commits.
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'A', 'MCP'), (?, 'B', 'MCP')`, codeA, codeB).Error; err != nil {
t.Fatal(err)
}
spy.reset()
manager := env.admin(t, permAccessGroups)
env.expect(t, http.StatusOK, http.MethodPut, groupPath(groupA.ID), manager, map[string]any{"name": groupA.Name, "code": *groupA.Code, "permissions": map[string]int{codeA: 1, codeB: 1}})
if !spy.has("user", user.ID, pact.MCPSubjectPermissions) {
t.Fatalf("group save fired %+v", spy.events)
}
// Deleting a group fires permissions for its members once it commits.
spy.reset()
env.expect(t, http.StatusOK, http.MethodDelete, groupPath(groupB.ID), manager, nil)
if !spy.has("user", user.ID, pact.MCPSubjectPermissions) {
t.Fatalf("group delete fired %+v", spy.events)
}
// Saving the user on the Users screen fires permissions too.
spy.reset()
users := env.admin(t, permAccessUsers)
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), users, map[string]any{"name": "MCP renamed", "email": user.Email})
if !spy.has("user", user.ID, pact.MCPSubjectPermissions) {
t.Fatalf("user save fired %+v", spy.events)
}
}