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:
210
backend_link_test.go
Normal file
210
backend_link_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
95
frontend_permission_catalog_test.go
Normal file
95
frontend_permission_catalog_test.go
Normal 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
117
mcp_guard_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user