Files
sm-user-plugin/frontend_permission_catalog_smoke_test.go
Jakub Zych 0f545f5c81 feat: user MCP guard provider, plugin frontend permissions and the user_me tool
- classes.FrontendPermissionCatalog: the seeded rows, then codes activated
  plugins declare through pact.HasFrontendPermissions (seeded row wins);
  both permission editors offer it
- classes.MCPUserGuard (guard "user"): reads user.api_token, user.scope:*
  and user.permission:*, allows with the route's checks (session scopes,
  group grants, every code), dispatches with an in-memory token credential,
  resolves usr_ personal tokens; registered in *pact.MCPGuards and published
  as pact.MCPPersonalTokenResolver
- GET /_user/api/v1/token/me (user.api_token, user.scope:read) and the MCP
  tool me (user_me)
- pact.MCPSubjectChanged on token revoke, Users saves and User Groups saves
  and deletes
2026-10-23 12:08:38 +02:00

113 lines
4.2 KiB
Go

package user
import (
"net/http"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// The frontend permission codes the acmeBlogPermissions test plugin declares.
const (
acmeBlogManage = "acme.blog.manage"
// acmeBlogSeeded is also a seeded row of golem15_user_frontend_permissions
// in the smoke, with another label: the seeded row wins.
acmeBlogSeeded = "acme.blog.seeded"
)
// acmeBlogPermissions is a plugin that only declares frontend permission
// codes (pact.HasFrontendPermissions). It is registered for the test binary
// and activated only by the tests that name it.
type acmeBlogPermissions struct{}
func (acmeBlogPermissions) ID() string { return "acme.blog" }
func (acmeBlogPermissions) Requires() []string { return nil }
func (acmeBlogPermissions) Register(*backpack.App) error { return nil }
func (acmeBlogPermissions) Boot(*backpack.App) error { return nil }
func (acmeBlogPermissions) FrontendPermissions() []pact.FrontendPermission {
return []pact.FrontendPermission{
{Code: acmeBlogManage, Label: "Manage blog posts", Tab: "Blog", Comment: "Create, edit and delete posts."},
{Code: acmeBlogSeeded, Label: "Plugin label", Tab: "Blog"},
{Code: acmeBlogManage, Label: "Declared twice", Tab: "Blog"},
}
}
func init() { party.Register(acmeBlogPermissions{}) }
type offeredPermission struct {
Code string `json:"code"`
Label string `json:"label"`
Tab string `json:"tab"`
Comment string `json:"comment"`
}
// permissionOptions reads the options of the permissions field of a form
// schema.
func (e *adminEnv) permissionOptions(t *testing.T, token, path string) []offeredPermission {
t.Helper()
var form struct {
Fields []struct {
Name string `json:"name"`
Options []offeredPermission `json:"permissionOptions"`
} `json:"fields"`
}
adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, adminAPI(path+"/schema/form"), token, nil), &form)
for _, field := range form.Fields {
if field.Name == "permissions" {
return field.Options
}
}
t.Fatalf("%s has no permissions field", path)
return nil
}
// TestFrontendPermissionCatalogSmoke: both permission editors offer the
// seeded rows first, then the codes an activated plugin declares; a seeded
// row wins over a declared code and a code is offered once.
func TestFrontendPermissionCatalogSmoke(t *testing.T) {
env := newAdminEnvWith(t, []string{"acme.blog"})
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'Seeded label', 'Blog') ON CONFLICT (code) DO NOTHING`, acmeBlogSeeded).Error; err != nil {
t.Fatal(err)
}
var seeded int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_frontend_permissions`).Scan(&seeded).Error; err != nil {
t.Fatal(err)
}
token := env.admin(t, permAccessUsers, permAccessGroups)
for _, path := range []string{adminUsersPath, adminGroupsPath} {
options := env.permissionOptions(t, token, path)
if int64(len(options)) != seeded+1 {
t.Fatalf("%s offers %d options, want the %d seeded rows and one declared code: %+v", path, len(options), seeded, options)
}
last := options[len(options)-1]
if last != (offeredPermission{Code: acmeBlogManage, Label: "Manage blog posts", Tab: "Blog", Comment: "Create, edit and delete posts."}) {
t.Fatalf("%s: last option = %+v, want the declared acme.blog.manage", path, last)
}
found := 0
for _, option := range options[:len(options)-1] {
if option.Code == acmeBlogManage {
t.Fatalf("%s offers %s twice", path, acmeBlogManage)
}
if option.Code == acmeBlogSeeded {
found++
if option.Label != "Seeded label" {
t.Fatalf("%s: duplicate code label = %q, want the seeded row's", path, option.Label)
}
}
}
if found != 1 {
t.Fatalf("%s offers %s %d times", path, acmeBlogSeeded, found)
}
}
// Without the declaring plugin the editors offer the seeded rows only.
plain := newAdminEnv(t)
plainToken := plain.admin(t, permAccessUsers)
if options := plain.permissionOptions(t, plainToken, adminUsersPath); int64(len(options)) != seeded {
t.Fatalf("without acme.blog: %d options, want %d", len(options), seeded)
}
}