Files
sm-user-plugin/backend_link_admin_smoke_test.go
Jakub Zych 6f1cb1a774 feat: link and unlink an administrator's frontend account on the Administrators screen
- golem15.users.manage_backend_links (no default role), en and pl labels
- record actions on summercms.cabana.users: link_frontend_account asks for
  the frontend user's email (exact, case-insensitive, active users only),
  records linked_by and fires the link events; unlink_frontend_account
  (danger) removes the link
- a taken or unknown account answers 422 on the prompt
2026-10-23 12:14:21 +02:00

152 lines
6.4 KiB
Go

package user
import (
"context"
"encoding/json"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
const permManageBackendLinks = "golem15.users.manage_backend_links"
// administratorPath is the admin API path of one administrator on the
// Administrators screen, with an optional suffix.
func administratorPath(id uint, suffix string) string {
return adminAPI("/summercms/cabana/users/" + strconv.FormatUint(uint64(id), 10) + suffix)
}
// superAdmin is admin with the superuser flag set.
func (e *adminEnv) superAdmin(t *testing.T) string {
t.Helper()
token := e.admin(t)
if err := e.gdb.Exec(`UPDATE backend_users SET is_superuser = TRUE WHERE id = ?`, adminID(t, token)).Error; err != nil {
t.Fatal(err)
}
return token
}
// administratorActions returns the record actions the Administrators screen
// offers on an administrator.
func (e *adminEnv) administratorActions(t *testing.T, token string, id uint) []string {
t.Helper()
rec := e.expect(t, http.StatusOK, http.MethodGet, administratorPath(id, ""), token, nil)
var meta struct {
Actions []struct {
Name string `json:"name"`
} `json:"actions"`
}
if err := json.Unmarshal(adminDecode(t, rec).Meta, &meta); err != nil {
t.Fatal(err)
}
names := []string{}
for _, action := range meta.Actions {
if strings.HasSuffix(action.Name, "frontend_account") {
names = append(names, action.Name)
}
}
return names
}
// TestBackendLinkAdminSmoke: on the Administrators screen an administrator
// with golem15.users.manage_backend_links links a frontend account to
// another administrator by its exact email (any letter case) and unlinks
// it; linked_by records the caller, the event fires, a taken or unknown
// account is a 422 on the prompt, and an administrator without the
// permission is neither offered the actions nor allowed to run them.
func TestBackendLinkAdminSmoke(t *testing.T) {
env := newAdminEnv(t)
var mu sync.Mutex
var linked []models.BackendLink
env.app.Events.Listen("test.backend_linked", func(_ context.Context, e *classes.BackendLinkedEvent) error {
mu.Lock()
linked = append(linked, *e.Link)
mu.Unlock()
return nil
})
stamp := adminStamp()
editor := env.seedUser(t, "editor", func(u *models.User) { u.Email = "editor-" + stamp + "@example.test" })
second := env.seedUser(t, "second", nil)
targetToken := env.admin(t)
target := adminID(t, targetToken)
super := env.superAdmin(t)
superID := adminID(t, super)
resolver, _ := env.app.Lookup[pact.MCPLinkedBackendResolver]()
if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"link_frontend_account"}) {
t.Fatalf("offered before the link = %v", got)
}
link := func(token, value string) *http.Response {
t.Helper()
rec := env.call(t, http.MethodPost, administratorPath(target, "/actions/link_frontend_account"), token, map[string]any{"payload": value})
return rec.Result()
}
// Unknown email: 422 on payload with the unknown_user text.
rec := env.call(t, http.MethodPost, administratorPath(target, "/actions/link_frontend_account"), super, map[string]any{"payload": "nobody-" + stamp + "@example.test"})
if rec.Code != http.StatusUnprocessableEntity || !slices.Contains(adminDecode(t, rec).Error.Details["payload"], "No active frontend user has this email.") {
t.Fatalf("unknown email = %d %s", rec.Code, rec.Body.String())
}
// The email matches whatever its letter case.
if res := link(super, " Editor-"+stamp+"@Example.TEST "); res.StatusCode != http.StatusOK {
t.Fatalf("set = %d", res.StatusCode)
}
var row models.BackendLink
if err := env.gdb.Where("backend_user_id = ?", target).Take(&row).Error; err != nil || row.UserID != editor.ID || row.LinkedBy == nil || *row.LinkedBy != superID {
t.Fatalf("link row = %+v %v", row, err)
}
if got, found, err := resolver.LinkedBackendUser(context.Background(), editor.ID); err != nil || !found || got.ID != target {
t.Fatalf("LinkedBackendUser = %+v %v %v", got, found, err)
}
mu.Lock()
if len(linked) != 1 || linked[0].UserID != editor.ID || linked[0].BackendUserID != target {
t.Fatalf("BackendLinkedEvent saw %+v", linked)
}
mu.Unlock()
if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"unlink_frontend_account"}) {
t.Fatalf("offered after the link = %v", got)
}
// The administrator is linked: a second set no longer applies (409),
// and the same frontend user cannot be linked to another administrator.
if res := link(super, second.Email); res.StatusCode != http.StatusConflict {
t.Fatalf("second set on a linked administrator = %d", res.StatusCode)
}
otherToken := env.admin(t)
other := adminID(t, otherToken)
rec = env.call(t, http.MethodPost, administratorPath(other, "/actions/link_frontend_account"), super, map[string]any{"payload": editor.Email})
if rec.Code != http.StatusUnprocessableEntity || !slices.Contains(adminDecode(t, rec).Error.Details["payload"], "This administrator or this frontend user is already linked.") {
t.Fatalf("frontend user linked twice = %d %s", rec.Code, rec.Body.String())
}
// Without the permission: not offered, and refused.
limited := env.admin(t, cabana.PermissionManageUsers)
if got := env.administratorActions(t, limited, target); len(got) != 0 {
t.Fatalf("limited administrator is offered %v", got)
}
env.expect(t, http.StatusForbidden, http.MethodPost, administratorPath(target, "/actions/unlink_frontend_account"), limited, map[string]any{})
env.expect(t, http.StatusForbidden, http.MethodPost, administratorPath(other, "/actions/link_frontend_account"), limited, map[string]any{"payload": second.Email})
manager := env.admin(t, cabana.PermissionManageUsers, permManageBackendLinks)
if got := env.administratorActions(t, manager, target); !slices.Equal(got, []string{"unlink_frontend_account"}) {
t.Fatalf("manager is offered %v", got)
}
// Unlink.
env.expect(t, http.StatusOK, http.MethodPost, administratorPath(target, "/actions/unlink_frontend_account"), manager, map[string]any{})
if _, found, err := resolver.LinkedBackendUser(context.Background(), editor.ID); err != nil || found {
t.Fatalf("after unlink found=%v err=%v", found, err)
}
if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"link_frontend_account"}) {
t.Fatalf("offered after the unlink = %v", got)
}
}