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) } }