Files
sm-user-plugin/admin_groups_edge_test.go
Jakub Zych a848aa17d6 test: edge cases of the Users, User Groups and Organisations admin controllers
- each controller driven directly on records of another type, an application that is not booted, one without a database and one whose database fails: a guard that cannot read answers a plain error, never a pass
- D-30: a superuser passes the takeover guard; an unreadable membership is the opaque 500 with nothing changed
- unknown partials, undeclared filters and sorts, options of fields that have none, search and sort on the three lists
- a name-only update of a privileged group, validation before the guard, a seeded group protected through the config list
- known gaps pinned: boolean values in a group's permissions, organisation_role after an unlink
2026-10-05 15:23:23 +02:00

260 lines
13 KiB
Go

package user
import (
"context"
"net/http"
"reflect"
"testing"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestAdminGroupsControllerGuards drives the User Groups controller directly
// on the inputs the HTTP tests cannot produce. A guard that cannot read what
// it needs answers a plain error (the opaque 500), never a pass.
func TestAdminGroupsControllerGuards(t *testing.T) {
ctx := context.Background()
const id = "golem15.user.usergroups"
notGroup := &models.User{}
var nilGroup *models.UserGroup
admin, plain := "admin", "plain-code"
damaged := "{not json"
broken, _ := brokenApp(t)
env := newAdminEnv(t)
sets := map[string]adminControllerSet{
"no accessor": adminControllersOn(nil),
"not booted": adminControllersOn(func() *backpack.App { return nil }),
"no database": adminControllersOn(func() *backpack.App { return bareApp(t) }),
"a failing handle": adminControllersOn(func() *backpack.App { return broken }),
"the live database": adminControllersOn(func() *backpack.App { return env.app }),
}
for name, set := range sets {
t.Run(name, func(t *testing.T) {
ctl := set[id]
for _, record := range []any{notGroup, nilGroup, nil} {
wantPlainError(t, "FormBeforeCreate", ctl.(pact.FormBeforeCreate).FormBeforeCreate(ctx, record))
wantPlainError(t, "FormBeforeUpdate", ctl.(pact.FormBeforeUpdate).FormBeforeUpdate(ctx, record))
wantPlainError(t, "FormBeforeDelete", ctl.(pact.FormBeforeDelete).FormBeforeDelete(ctx, record))
wantPlainError(t, "FormAfterDelete", ctl.(pact.FormAfterDelete).FormAfterDelete(ctx, record))
editor := ctl.(cabana.PermissionEditorProvider)
_, err := editor.AdminPermissionValues(ctx, "permissions", record)
wantPlainError(t, "AdminPermissionValues", err)
wantPlainError(t, "AdminSetPermissionValues", editor.AdminSetPermissionValues(ctx, "permissions", record, nil))
}
editor := ctl.(cabana.PermissionEditorProvider)
if _, err := editor.AdminPermissionOptions(ctx, "name"); err == nil {
t.Fatal("permission options for another field")
}
if _, err := editor.AdminPermissionValues(ctx, "name", &models.UserGroup{}); err == nil {
t.Fatal("permission values for another field")
}
if err := editor.AdminSetPermissionValues(ctx, "name", &models.UserGroup{}, nil); err == nil {
t.Fatal("permission write for another field")
}
// A stored value that is not a JSON object is an error, never an
// empty set an edit would then save over it.
_, err := editor.AdminPermissionValues(ctx, "permissions", &models.UserGroup{Permissions: &damaged})
wantPlainError(t, "damaged stored permissions", err)
if values, err := editor.AdminPermissionValues(ctx, "permissions", &models.UserGroup{}); err != nil || len(values) != 0 {
t.Fatalf("no stored permissions = %v %v", values, err)
}
if ctl.(pact.ListExtendQuery).ListExtendQuery(ctx, nil) != nil {
t.Fatal("ListExtendQuery invented a handle")
}
wantPlainError(t, "FormAfterDelete without a transaction", ctl.(pact.FormAfterDelete).FormAfterDelete(ctx, &models.UserGroup{ID: 1}))
// Without a principal the privileged code is refused on every
// path, with the screen's 403 (D-06).
for what, err := range map[string]error{
"create": ctl.(pact.FormBeforeCreate).FormBeforeCreate(ctx, &models.UserGroup{Name: "Admins", Code: &admin}),
"delete": ctl.(pact.FormBeforeDelete).FormBeforeDelete(ctx, &models.UserGroup{ID: 1, Name: "Admins", Code: &admin}),
} {
forbidden, ok := err.(*cabana.ForbiddenError)
if !ok || forbidden.Message != "golem15.user::lang.groups.privileged_forbidden" || forbidden.Details["code"] == nil {
t.Fatalf("%s of a privileged group without a principal: %v", what, err)
}
}
// With the extra permission, and for a superuser, it passes.
for _, principal := range []*bouncer.Principal{
{PermissionGrants: map[string]bool{permManagePrivileged: true}},
{IsSuperuser: true},
} {
as := bouncer.WithUser(ctx, principal)
if err := ctl.(pact.FormBeforeCreate).FormBeforeCreate(as, &models.UserGroup{Name: "Admins", Code: &admin}); err != nil {
t.Fatalf("create for %+v: %v", principal, err)
}
if err := ctl.(pact.FormBeforeDelete).FormBeforeDelete(as, &models.UserGroup{ID: 1, Code: &admin}); err != nil {
t.Fatalf("delete for %+v: %v", principal, err)
}
}
// An ordinary code and a group without a code need nothing extra.
if err := ctl.(pact.FormBeforeCreate).FormBeforeCreate(ctx, &models.UserGroup{Name: "Plain", Code: &plain}); err != nil {
t.Fatal(err)
}
if err := ctl.(pact.FormBeforeDelete).FormBeforeDelete(ctx, &models.UserGroup{ID: 1}); err != nil {
t.Fatal(err)
}
// An update reads the stored code first: with no database, a
// failing one or an unknown group it is an error, not a pass.
wantPlainError(t, "update of an unreadable group", ctl.(pact.FormBeforeUpdate).FormBeforeUpdate(ctx, &models.UserGroup{ID: 987654321, Name: "Gone", Code: &plain}))
if name != "the live database" {
_, err := editor.AdminPermissionOptions(ctx, "permissions")
wantPlainError(t, "AdminPermissionOptions", err)
}
})
}
}
// TestAdminGroupsEdge covers the remaining branches of the User Groups
// screen through the admin API.
func TestAdminGroupsEdge(t *testing.T) {
stamp := adminStamp()
privilegedCode := "edgepriv-" + stamp
env := newAdminEnv(t, func(cfg *compass.Config) {
if err := cfg.Set("golem15.user.privileged_groups", []string{"admin", "guest", privilegedCode}); err != nil {
t.Fatal(err)
}
})
token := env.admin(t, permAccessGroups)
manager := env.admin(t, permAccessGroups, permManagePrivileged)
// A group update that keeps a privileged code needs nothing extra: the
// name and description change, the code stays.
privileged := env.seedGroup(t, "Edge privileged "+stamp, privilegedCode)
env.expect(t, http.StatusOK, http.MethodPut, groupPath(privileged.ID), token, map[string]any{"name": "Edge renamed " + stamp, "code": privilegedCode, "description": "kept"})
if got := env.group(t, privileged.ID); got.Name != "Edge renamed "+stamp || got.Code == nil || *got.Code != privilegedCode || got.Description == nil || *got.Description != "kept" {
t.Fatalf("group after a name-only update = %+v", got)
}
// The same administrator may not move the code away from, or onto, a
// privileged one; the manager may.
ordinary := env.seedGroup(t, "Edge ordinary "+stamp, "edgeord-"+stamp)
rec := env.expect(t, http.StatusForbidden, http.MethodPut, groupPath(privileged.ID), token, map[string]any{"code": "edgefree-" + stamp})
if message := adminDecode(t, rec).Error.Message; message != privilegedGroupCodeForbidden {
t.Fatalf("403 message = %q", message)
}
if got := env.group(t, privileged.ID); *got.Code != privilegedCode {
t.Fatalf("a refused re-code changed the code to %q", *got.Code)
}
env.expect(t, http.StatusOK, http.MethodPut, groupPath(ordinary.ID), manager, map[string]any{"name": "Edge ordinary " + stamp + " b"})
// Validation answers before the guard: a create that would be refused
// with 403 answers 422 when its body is also invalid, and saves nothing.
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminGroupsPath), token, map[string]any{"name": "Edge duplicate " + stamp, "code": privilegedCode})
if details := adminDecode(t, rec).Error.Details; len(details["code"]) == 0 {
t.Fatalf("422 details = %v", details)
}
var count int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM user_groups WHERE code = ?`, privilegedCode).Scan(&count).Error; err != nil || count != 1 {
t.Fatalf("groups with the privileged code = %d err = %v", count, err)
}
// A code with characters outside letters, digits, dash and underscore is
// a 422 on the field, in the request's language.
for _, code := range []string{"with space", "dot.ted", "sl/ash", "ąę"} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminGroupsPath), token, map[string]any{"name": "Edge bad code " + stamp, "code": code})
details := adminDecode(t, rec).Error.Details
if got := details["code"]; len(got) != 1 || got[0] != "The code may only contain letters, numbers, dashes and underscores." {
t.Fatalf("code %q: details = %v", code, details)
}
}
// An application protects a seeded group by naming its code in
// golem15.user.privileged_groups: the guest group then cannot be deleted
// or re-coded with golem15.users.access_groups alone.
var guestID uint
if err := env.gdb.Raw(`SELECT id FROM user_groups WHERE code = 'guest' ORDER BY id LIMIT 1`).Scan(&guestID).Error; err != nil {
t.Fatal(err)
}
if guestID == 0 {
guestID = env.seedGroup(t, "Guest", "guest").ID
}
env.expect(t, http.StatusForbidden, http.MethodDelete, groupPath(guestID), token, nil)
env.expect(t, http.StatusForbidden, http.MethodPut, groupPath(guestID), token, map[string]any{"code": "visitor-" + stamp})
if got := env.group(t, guestID); got == nil || got.Code == nil || *got.Code != "guest" {
t.Fatalf("the protected guest group = %+v", got)
}
// Search and sort on the list.
for _, name := range []string{"b", "a", "c"} {
env.seedGroup(t, "Sort "+stamp+" "+name, "sort-"+stamp+"-"+name)
}
order := func(query string) []string {
var rows []map[string]any
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminGroupsPath)+"?search=Sort%20"+stamp+"&"+query, token, nil), &rows)
codes := []string{}
for _, row := range rows {
code, _ := row["code"].(string)
codes = append(codes, code[len(code)-1:])
}
return codes
}
if got := order("sort=name&dir=asc"); !reflect.DeepEqual(got, []string{"a", "b", "c"}) {
t.Fatalf("name asc = %v", got)
}
if got := order("sort=code&dir=desc"); !reflect.DeepEqual(got, []string{"c", "b", "a"}) {
t.Fatalf("code desc = %v", got)
}
for _, query := range []string{"sort=permissions", "sort=description", "filter[code]=x"} {
env.expect(t, http.StatusUnprocessableEntity, http.MethodGet, adminAPI(adminGroupsPath)+"?"+query, token, nil)
}
// A group whose stored permissions are not a JSON object cannot be
// opened: the answer is the opaque 500, and the row is left as it is.
broken := env.seedGroup(t, "Edge damaged "+stamp, "edgedmg-"+stamp)
if err := env.gdb.Exec(`UPDATE user_groups SET permissions = '{"a":' WHERE id = ?`, broken.ID).Error; err != nil {
t.Fatal(err)
}
rec = env.expect(t, http.StatusInternalServerError, http.MethodGet, groupPath(broken.ID), token, nil)
if message := adminDecode(t, rec).Error.Message; message != "Server error" {
t.Fatalf("500 message = %q", message)
}
if got := env.group(t, broken.ID); got.Permissions == nil || *got.Permissions != `{"a":` {
t.Fatalf("damaged permissions after the read = %v", got.Permissions)
}
}
// TestAdminGroupsLegacyBooleanPermissions pins a known difference between
// the two readers of user_groups.permissions (12.1-SECURITY-REVIEW.md, open
// item 5): the API-token grants count a stored true as granted, the admin
// editor and the merged-permission resolver read only numbers. A group
// stored with booleans therefore shows unchecked, and an edit through the
// editor does not keep the boolean. The change is towards fewer grants,
// never more.
func TestAdminGroupsLegacyBooleanPermissions(t *testing.T) {
env := newAdminEnv(t)
token := env.admin(t, permAccessGroups)
stamp := adminStamp()
legacy, offered := "legacy.bool."+stamp, "offered.bool."+stamp
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'Legacy', 'Edge'), (?, 'Offered', 'Edge')`, legacy, offered).Error; err != nil {
t.Fatal(err)
}
group := env.seedGroup(t, "Edge boolean "+stamp, "edgebool-"+stamp)
if err := env.gdb.Exec(`UPDATE user_groups SET permissions = ? WHERE id = ?`, `{"`+legacy+`":true}`, group.ID).Error; err != nil {
t.Fatal(err)
}
var record struct {
Permissions map[string]int `json:"permissions"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, groupPath(group.ID), token, nil), &record)
if len(record.Permissions) != 0 {
t.Fatalf("a boolean value is shown as %v", record.Permissions)
}
// A save that does not carry the field leaves the stored value alone.
env.expect(t, http.StatusOK, http.MethodPut, groupPath(group.ID), token, map[string]any{"description": "untouched"})
if got := env.group(t, group.ID); got.Permissions == nil || *got.Permissions != `{"`+legacy+`":true}` {
t.Fatalf("permissions after a save without the field = %v", got.Permissions)
}
// A save that carries the field writes numbers only.
env.expect(t, http.StatusOK, http.MethodPut, groupPath(group.ID), token, map[string]any{"permissions": map[string]int{offered: 1}})
if got := env.group(t, group.ID); got.Permissions == nil || *got.Permissions != `{"`+offered+`":1}` {
t.Fatalf("permissions after an edit = %v", got.Permissions)
}
}