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.
192 lines
8.6 KiB
Go
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)
|
|
}
|
|
}
|