Files
sm-user-plugin/mcp_guard_test.go
Jakub Zych 72de822a84 test: backend link, frontend permission catalog and MCP guard matrices (phase 19.7 plan 05)
- TestBackendLink: the fresh sign-in rule at the second, 1:1 on the
  frontend side, the show route's three keys, SetLink and RemoveLink events
  (none after a rollback), deactivated or deleted administrators, and
  deactivating the frontend user
- TestFrontendPermissionCatalog: a second plugin's duplicate and an empty
  code dropped; a group saved with a declared code grants it
- TestUserMCPGuard: parser refusals, a soft-deleted user, an expired token,
  and the dispatch credential against the route's own user.scope:read
2026-10-23 14:09:36 +02:00

118 lines
4.8 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. 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")
}
})
}
// mustDispatchCredential returns the credential MCPDispatch builds for a
// read subject; it is never saved (ID 0).
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
}