- 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
260 lines
13 KiB
Go
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)
|
|
}
|
|
}
|