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
This commit is contained in:
Jakub Zych
2026-10-23 14:09:32 +02:00
parent bfcfea4ed7
commit 72de822a84
3 changed files with 422 additions and 0 deletions

210
backend_link_test.go Normal file
View File

@@ -0,0 +1,210 @@
package user
import (
"context"
"encoding/json"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// requestCreatedAt reads when the administrator's open link request was
// created.
func (e *adminEnv) requestCreatedAt(t *testing.T, adminID uint) time.Time {
t.Helper()
var created time.Time
if err := e.gdb.Raw(`SELECT created_at FROM golem15_user_backend_link_requests WHERE backend_user_id = ? AND used_at IS NULL ORDER BY id DESC LIMIT 1`, adminID).Scan(&created).Error; err != nil || created.IsZero() {
t.Fatalf("link request created_at = %v, %v", created, err)
}
return created
}
// TestBackendLink (D-03..D-08) extends the link smokes: the fresh sign-in
// rule at the second (an iat equal to the request's created_at confirms,
// one second earlier is stale_session); 1:1 on the frontend side too (a
// linked user cannot confirm another administrator's request); the show
// route exposes the administrator's login and name and the expiry only;
// SetLink records linked_by and fires BackendLinkedEvent and both subject
// events after commit, nothing on rollback; RemoveLink fires
// BackendUnlinkedEvent and both subject events; LinkedBackendUser answers
// false for a deactivated or soft-deleted administrator; deactivating the
// frontend user removes the link and fires deleted. Skips under -short.
func TestBackendLink(t *testing.T) {
env := newAdminEnv(t)
spy := env.spySubjects()
links, _ := env.app.Lookup[*classes.BackendLinks]()
resolver, _ := env.app.Lookup[pact.MCPLinkedBackendResolver]()
ctx := context.Background()
t.Run("fresh sign-in at the second", func(t *testing.T) {
token := env.admin(t)
admin := adminID(t, token)
user := env.seedUser(t, "iat", nil)
sub := strconv.FormatUint(uint64(user.ID), 10)
code := env.startLink(t, token)
created := env.requestCreatedAt(t, admin).Truncate(time.Second)
early := craftToken(t, sub, "early-"+adminStamp(), created.Add(-time.Second), time.Now().Add(time.Hour))
if status, body := env.confirmLink(t, early, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
t.Fatalf("iat one second early = %d %s", status, body)
}
exact := craftToken(t, sub, "exact-"+adminStamp(), created, time.Now().Add(time.Hour))
if status, body := env.confirmLink(t, exact, code); status != http.StatusOK {
t.Fatalf("iat equal to created_at = %d %s", status, body)
}
})
t.Run("a linked frontend user cannot take a second administrator", func(t *testing.T) {
first, second := env.admin(t), env.admin(t)
user := env.seedUser(t, "one-to-one", nil)
code := env.startLink(t, first)
if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusOK {
t.Fatalf("first link = %d %s", status, body)
}
code = env.startLink(t, second)
if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusConflict || !strings.Contains(body, `"already_linked"`) {
t.Fatalf("second administrator = %d %s", status, body)
}
})
t.Run("show exposes login, name and expiry only", func(t *testing.T) {
token := env.admin(t)
code := env.startLink(t, token)
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil)
var shown map[string]map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &shown); err != nil {
t.Fatal(err)
}
keys := func(m map[string]any) []string {
var out []string
for k := range m {
out = append(out, k)
}
slices.Sort(out)
return out
}
admin, _ := shown["data"]["admin"].(map[string]any)
if len(shown) != 1 || !slices.Equal(keys(shown["data"]), []string{"admin", "expires_at"}) || !slices.Equal(keys(admin), []string{"login", "name"}) {
t.Fatalf("show = %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "@") || strings.Contains(rec.Body.String(), code) {
t.Fatalf("show leaks an email or the code: %s", rec.Body.String())
}
})
t.Run("SetLink and RemoveLink events", func(t *testing.T) {
var mu sync.Mutex
var linked, unlinked int
env.app.Events.Listen("test.link_events", func(context.Context, *classes.BackendLinkedEvent) error {
mu.Lock()
linked++
mu.Unlock()
return nil
})
env.app.Events.Listen("test.link_events", func(context.Context, *classes.BackendUnlinkedEvent) error {
mu.Lock()
unlinked++
mu.Unlock()
return nil
})
setter := adminID(t, env.admin(t))
target := adminID(t, env.admin(t))
user := env.seedUser(t, "set-link", nil)
// A rolled-back SetLink fires no subject event.
spy.reset()
_ = lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error {
if _, err := links.SetLink(ctx, tx, user.ID, target, setter); err != nil {
t.Fatal(err)
}
return context.Canceled
})
if spy.has("user", user.ID, pact.MCPSubjectLink) || spy.has("backend", target, pact.MCPSubjectLink) {
t.Fatalf("a rolled-back SetLink fired %+v", spy.events)
}
var link *models.BackendLink
if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error {
var err error
link, err = links.SetLink(ctx, tx, user.ID, target, setter)
return err
}); err != nil {
t.Fatal(err)
}
if link.LinkedBy == nil || *link.LinkedBy != setter {
t.Fatalf("linked_by = %v, want %d", link.LinkedBy, setter)
}
if !spy.has("user", user.ID, pact.MCPSubjectLink) || !spy.has("backend", target, pact.MCPSubjectLink) {
t.Fatalf("SetLink fired %+v", spy.events)
}
spy.reset()
if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error {
return links.RemoveLink(ctx, tx, link)
}); err != nil {
t.Fatal(err)
}
if !spy.has("user", user.ID, pact.MCPSubjectLink) || !spy.has("backend", target, pact.MCPSubjectLink) {
t.Fatalf("RemoveLink fired %+v", spy.events)
}
mu.Lock()
defer mu.Unlock()
// The rolled-back SetLink fired BackendLinkedEvent inside its
// transaction too (listeners run with the transaction).
if linked != 2 || unlinked != 1 {
t.Fatalf("BackendLinkedEvent %d, BackendUnlinkedEvent %d", linked, unlinked)
}
})
t.Run("deactivated or deleted administrator", func(t *testing.T) {
token := env.admin(t)
admin := adminID(t, token)
user := env.seedUser(t, "admin-gone", nil)
if status, body := env.confirmLink(t, freshUserToken(t, user), env.startLink(t, token)); status != http.StatusOK {
t.Fatalf("link = %d %s", status, body)
}
check := func(want bool, why string) {
t.Helper()
if _, found, err := resolver.LinkedBackendUser(ctx, user.ID); err != nil || found != want {
t.Fatalf("%s: found = %v, %v", why, found, err)
}
}
check(true, "active")
env.gdb.Exec(`UPDATE backend_users SET is_activated = FALSE WHERE id = ?`, admin)
check(false, "deactivated administrator")
env.gdb.Exec(`UPDATE backend_users SET is_activated = TRUE, deleted_at = NOW() WHERE id = ?`, admin)
check(false, "soft-deleted administrator")
env.gdb.Exec(`UPDATE backend_users SET deleted_at = NULL WHERE id = ?`, admin)
check(true, "restored administrator")
})
t.Run("deactivating the frontend user removes the link", func(t *testing.T) {
token := env.admin(t)
admin := adminID(t, token)
user := env.seedUser(t, "deactivated", nil)
if status, body := env.confirmLink(t, freshUserToken(t, user), env.startLink(t, token)); status != http.StatusOK {
t.Fatalf("link = %d %s", status, body)
}
spy.reset()
if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error {
return controllers.FireDeactivatingEvent(ctx, env.app, user, tx)
}); err != nil {
t.Fatal(err)
}
if link, err := links.LinkOf(ctx, user.ID); err != nil || link != nil {
t.Fatalf("link after deactivation = %+v, %v", link, err)
}
if !spy.has("user", user.ID, pact.MCPSubjectDeleted) || !spy.has("backend", admin, pact.MCPSubjectLink) {
t.Fatalf("deactivation fired %+v", spy.events)
}
})
}

View File

@@ -0,0 +1,95 @@
package user
import (
"context"
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
const acmeShopOrders = "acme.shop.orders"
// acmeShopPermissions declares a code of its own and acme.blog's manage code
// again, under another label: activated after acme.blog, its duplicate is
// dropped.
type acmeShopPermissions struct{}
func (acmeShopPermissions) ID() string { return "acme.shop" }
func (acmeShopPermissions) Requires() []string { return nil }
func (acmeShopPermissions) Register(*backpack.App) error { return nil }
func (acmeShopPermissions) Boot(*backpack.App) error { return nil }
func (acmeShopPermissions) FrontendPermissions() []pact.FrontendPermission {
return []pact.FrontendPermission{
{Code: acmeBlogManage, Label: "Shop's copy", Tab: "Shop"},
{Code: acmeShopOrders, Label: "Manage orders", Tab: "Shop"},
{Code: " ", Label: "Empty code", Tab: "Shop"},
}
}
func init() { party.Register(acmeShopPermissions{}) }
// TestFrontendPermissionCatalog (D-09) extends the smoke: with two declaring
// plugins the editors offer the seeded rows, then the first plugin's codes,
// then the second's, a code the first already declared and an empty code
// left out; a group saved with a declared code keeps it, and the member's
// grants (what user.permission checks) include it. Skips under -short.
func TestFrontendPermissionCatalog(t *testing.T) {
env := newAdminEnvWith(t, []string{"acme.blog", "acme.shop"})
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)
t.Run("second plugin's duplicate dropped", func(t *testing.T) {
for _, path := range []string{adminUsersPath, adminGroupsPath} {
options := env.permissionOptions(t, token, path)
declared := options[seeded:]
var codes []string
for _, o := range declared {
codes = append(codes, o.Code)
}
if len(declared) != 2 || declared[len(declared)-2].Code != acmeBlogManage || declared[len(declared)-2].Label != "Manage blog posts" ||
declared[len(declared)-1] != (offeredPermission{Code: acmeShopOrders, Label: "Manage orders", Tab: "Shop"}) {
t.Fatalf("%s declared options = %+v", path, declared)
}
seen := map[string]int{}
for _, o := range options {
seen[o.Code]++
if o.Code == "" || o.Code == " " {
t.Fatalf("%s offers an empty code", path)
}
}
if seen[acmeBlogManage] != 1 || seen[acmeShopOrders] != 1 {
t.Fatalf("%s codes offered %v (declared %v)", path, seen, codes)
}
}
})
t.Run("group saved with a declared code grants it", func(t *testing.T) {
stamp := adminStamp()
group := env.seedGroup(t, "Orders "+stamp, "orders-"+stamp)
member := env.seedUser(t, "orders", nil)
env.join(t, member, group)
env.expect(t, http.StatusOK, http.MethodPut, groupPath(group.ID), token, map[string]any{
"name": group.Name, "code": *group.Code, "permissions": map[string]int{acmeShopOrders: 1},
})
var saved models.UserGroup
if err := env.gdb.First(&saved, group.ID).Error; err != nil || saved.Permissions == nil || !strings.Contains(*saved.Permissions, `"`+acmeShopOrders+`"`) {
t.Fatalf("saved permissions = %v, %v", saved.Permissions, err)
}
grants, err := classes.UserPermissionGrants(context.Background(), env.gdb, member.ID)
if err != nil || !grants[acmeShopOrders] {
t.Fatalf("member grants = %v, %v", grants, err)
}
})
}

117
mcp_guard_test.go Normal file
View File

@@ -0,0 +1,117 @@
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
}