test(cabana): cover the Roles form's holders list (quick-261028-rqg)

- TestRolesHoldersPartial (Docker-free): users partial field, view model
  guard, links, state lines, escaped strings, empty line, note
- TestRolesHolders (Postgres): D-03 hiding, links by manage_users, states,
  empty role, 100-row cap with note, 403 without manage_roles
This commit is contained in:
Jakub Zych
2026-10-28 21:37:22 +01:00
parent 8cb56a4c51
commit 653d95a5b9
3 changed files with 178 additions and 0 deletions

View File

@@ -420,3 +420,5 @@ The toolbar and widget action run log is covered by the Postgres test TestAdminA
A Detached bulk action (`pact.AdminBulkAction.Detached`) is covered by the Postgres test TestDetachedBulkAction in `bulk_detached_test.go`, on the roster fixture with two test-only actions: Run sees no transaction (`TxFromContext`) and holds no row lock (another connection's `SELECT ... FOR UPDATE NOWAIT` succeeds during Run); the empty, absent and partial-selection rules match the locked path; a `ValidationError` from Run is 422 and the rows written before it stay written; the same action without Detached still runs in the transaction holding the row locks. `scripts/check-phase18.1.sh` requires it in its postgres stage. A Detached bulk action (`pact.AdminBulkAction.Detached`) is covered by the Postgres test TestDetachedBulkAction in `bulk_detached_test.go`, on the roster fixture with two test-only actions: Run sees no transaction (`TxFromContext`) and holds no row lock (another connection's `SELECT ... FOR UPDATE NOWAIT` succeeds during Run); the empty, absent and partial-selection rules match the locked path; a `ValidationError` from Run is 422 and the rows written before it stay written; the same action without Detached still runs in the transaction holding the row locks. `scripts/check-phase18.1.sh` requires it in its postgres stage.
The localization of refusals is covered by Docker-free unit tests in `localize_error_test.go` (TestLocalizeControllerErrorValidation, TestLocalizeControllerErrorForbidden and TestWriteCRUDErrorLocalized, on a two-locale catalog; the plugin's error value stays unchanged and text without `::` is never looked up) and by the Postgres tests TestValidationErrorLocalized (a form hook's `cabana.ValidationError` in en and pl) and TestReorderHookRefusalLocalized (both refusal types from `pact.ReorderAfterSave`). The localization of refusals is covered by Docker-free unit tests in `localize_error_test.go` (TestLocalizeControllerErrorValidation, TestLocalizeControllerErrorForbidden and TestWriteCRUDErrorLocalized, on a two-locale catalog; the plugin's error value stays unchanged and text without `::` is never looked up) and by the Postgres tests TestValidationErrorLocalized (a form hook's `cabana.ValidationError` in en and pl) and TestReorderHookRefusalLocalized (both refusal types from `pact.ReorderAfterSave`).
The Roles form's holders list is covered by the Docker-free TestRolesHoldersPartial in `backend_screens_test.go` (the users partial field on the update form's Administrators tab, the view model guard, links, state lines, escaped holder strings, the empty line and the note) and the Postgres test TestRolesHolders in `roles_postgres_test.go` (superusers hidden from non-superuser editors, D-03; deleted and deactivated holders; preview links only with `cabana.PermissionManageUsers`; the empty role; the 100-row cap with its note; 403 without `cabana.PermissionManageRoles`).

View File

@@ -2,6 +2,7 @@ package cabana
import ( import (
"context" "context"
"encoding/json"
"io/fs" "io/fs"
"strings" "strings"
"testing" "testing"
@@ -236,3 +237,85 @@ func TestBackendScreensActivation(t *testing.T) {
t.Fatalf("holder of %s sees %+v", PermissionManageRoles, settings) t.Fatalf("holder of %s sees %+v", PermissionManageRoles, settings)
} }
} }
// TestRolesHoldersPartial: the Roles form carries the read-only users
// partial on the Administrators tab of the update form, its view model
// passes the curated-model guard, and the template renders links, states,
// the empty line and the note, escaping holder strings.
func TestRolesHoldersPartial(t *testing.T) {
reg, err := compileWithScreens(t, nil)
if err != nil {
t.Fatal(err)
}
cc, ok := reg.Get(RolesControllerID)
if !ok || cc.Form == nil {
t.Fatal("roles controller has no form")
}
var field *FormField
for i := range cc.Form.Fields {
if cc.Form.Fields[i].Name == "users" {
field = &cc.Form.Fields[i]
}
}
if field == nil {
t.Fatal("roles form has no users field")
}
if field.Type != "partial" || field.Path != "users" || field.Tab != "backend::lang.user.menu_label" ||
field.Context == nil || len(field.Context.values) != 1 || field.Context.values[0] != "update" {
t.Fatalf("users field = %+v (context %+v)", *field, field.Context)
}
partial := cc.partials["users"]
if partial == nil || !cc.formPartials["users"] {
t.Fatalf("users partial compiled=%v form=%v", partial != nil, cc.formPartials["users"])
}
if reason := refusedViewModel(cc, roleHoldersView{Rows: []roleHolderRow{{Login: "x", States: []string{"k"}}}}); reason != "" {
t.Fatalf("view model refused: %s", reason)
}
render := func(view roleHoldersView) string {
t.Helper()
nodes, err := partial.render(context.Background(), nil, view)
if err != nil {
t.Fatal(err)
}
return nodesJSON(t, nodes)
}
linked := render(roleHoldersView{Rows: []roleHolderRow{{
Login: "ada", FirstName: "Ada", LastName: "Lovelace", Email: "ada@example.test",
Link: "/backend/summercms/cabana/users/7/preview",
States: []string{"backend::lang.user.superuser", "backend::lang.user.state_deleted"},
}}})
for _, want := range []string{`"tag":"table"`, `"tag":"a"`, `"href":"/backend/summercms/cabana/users/7/preview"`,
`"text":"ada"`, `"text":"ada@example.test"`, `"text":"backend::lang.user.login"`} {
if !strings.Contains(linked, want) {
t.Fatalf("linked render lacks %s: %s", want, linked)
}
}
if got := strings.Count(linked, "summer-table__sub"); got != 2 {
t.Fatalf("state lines = %d, want 2: %s", got, linked)
}
plain := render(roleHoldersView{Rows: []roleHolderRow{{Login: "<script>x</script>"}}})
if strings.Contains(plain, `"tag":"a"`) || strings.Contains(plain, `"tag":"script"`) || strings.Contains(plain, "summer-table__sub") {
t.Fatalf("plain render = %s", plain)
}
escaped, err := json.Marshal("<script>x</script>")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(plain, `"text":`+string(escaped)) {
t.Fatalf("login not rendered as escaped text: %s", plain)
}
if empty := render(roleHoldersView{Empty: true}); !strings.Contains(empty, `"text":"backend::lang.user.role.users_empty"`) || strings.Contains(empty, `"tag":"table"`) {
t.Fatalf("empty render = %s", empty)
}
if noted := render(roleHoldersView{Rows: []roleHolderRow{{Login: "ada"}}, Note: "Showing the first 100 of 101"}); !strings.Contains(noted, `"text":"Showing the first 100 of 101"`) {
t.Fatalf("note render = %s", noted)
}
nodes, err := partial.render(context.Background(), nil, roleHoldersView{})
if err != nil || len(nodes) != 0 {
t.Fatalf("zero view = %v, %v", nodes, err)
}
}

View File

@@ -336,3 +336,96 @@ func (e *screensEnv) reloadRoleByCode(t *testing.T, code string) cabana.BackendU
} }
return role return role
} }
// TestRolesHolders: the Roles update form's users partial lists the role's
// holders in the editor's D-03 scope, marks their states, links logins only
// for editors holding backend.manage_users, shows the empty line, caps the
// rows at 100 with a note and is closed to editors without
// backend.manage_roles.
func TestRolesHolders(t *testing.T) {
env := newScreensEnv(t)
role := env.seedRole(t, roleSeed{Name: "holders"})
active := env.seedAdmin(t, adminSeed{Name: "holder-active", Role: role.ID})
inactive := env.seedAdmin(t, adminSeed{Name: "holder-inactive", Role: role.ID, Inactive: true})
deleted := env.seedAdmin(t, adminSeed{Name: "holder-deleted", Role: role.ID, Deleted: true})
super := env.seedAdmin(t, adminSeed{Name: "holder-super", Role: role.ID, Superuser: true})
other := env.seedAdmin(t, adminSeed{Name: "holder-other"})
holders := []cabana.BackendUser{active, inactive, deleted, super}
superEditor := env.seedAdmin(t, adminSeed{Name: "holders-super-editor", Superuser: true})
rolesOnly := env.seedAdmin(t, adminSeed{Name: "holders-roles-only",
RolePermissions: map[string]int{cabana.PermissionManageRoles: 1}})
rolesAndUsers := env.seedAdmin(t, adminSeed{Name: "holders-roles-users",
RolePermissions: map[string]int{cabana.PermissionManageRoles: 1, cabana.PermissionManageUsers: 1}})
outsider := env.seedAdmin(t, adminSeed{Name: "holders-outsider"})
partial := func(roleID uint, token string) string {
t.Helper()
body := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/users?id=%d", rolesPath, roleID), token, nil)
return fmt.Sprint(body["data"])
}
link := func(id uint) string { return fmt.Sprintf("/backend/summercms/cabana/users/%d/preview", id) }
// A superuser editor sees every holder, superusers included, with
// their states and preview links; an admin of another role is absent.
text := partial(role.ID, env.login(t, superEditor.Login))
for _, holder := range holders {
if !strings.Contains(text, holder.Login) || !strings.Contains(text, link(holder.ID)) {
t.Fatalf("superuser editor: holder %s (link %s) missing: %s", holder.Login, link(holder.ID), text)
}
}
for _, state := range []string{"Superuser", "Deactivated", "Deleted"} {
if !strings.Contains(text, "text:"+state) {
t.Fatalf("superuser editor: state %s missing: %s", state, text)
}
}
if strings.Contains(text, other.Login) {
t.Fatalf("superuser editor: admin of another role listed: %s", text)
}
// D-03: a non-superuser editor never sees the superuser holder, and
// without backend.manage_users gets no links.
text = partial(role.ID, env.login(t, rolesOnly.Login))
if strings.Contains(text, super.Login) || strings.Contains(text, "Superuser") {
t.Fatalf("D-03: superuser holder shown to a non-superuser editor: %s", text)
}
if !strings.Contains(text, active.Login) || !strings.Contains(text, deleted.Login) || !strings.Contains(text, inactive.Login) {
t.Fatalf("manage_roles editor: holders missing: %s", text)
}
if strings.Contains(text, "/summercms/cabana/users/") {
t.Fatalf("manage_roles editor without manage_users got preview links: %s", text)
}
text = partial(role.ID, env.login(t, rolesAndUsers.Login))
if strings.Contains(text, super.Login) {
t.Fatalf("D-03: superuser holder shown to a non-superuser editor: %s", text)
}
if !strings.Contains(text, link(active.ID)) {
t.Fatalf("manage_users editor: preview link missing: %s", text)
}
// A role nobody holds shows the empty line.
superToken := env.login(t, superEditor.Login)
empty := env.seedRole(t, roleSeed{Name: "holders-empty"})
if text := partial(empty.ID, superToken); !strings.Contains(text, "No administrator has this role.") {
t.Fatalf("empty role: %s", text)
}
// Without backend.manage_roles the partial route is denied.
env.expectRefusal(t, http.StatusForbidden, http.MethodGet, fmt.Sprintf("%s/partials/users?id=%d", rolesPath, role.ID),
env.login(t, outsider.Login), nil, "")
// 101 holders: the first 100 are listed, with the note naming the total.
crowded := env.seedRole(t, roleSeed{Name: "holders-crowded"})
prefix := "crowd-" + env.stamp + "-"
for i := 0; i < 101; i++ {
env.seedAdmin(t, adminSeed{Name: "crowd", Role: crowded.ID})
}
text = partial(crowded.ID, superToken)
// Each listed login appears twice: as the login and in the email.
if got := strings.Count(text, prefix); got != 200 {
t.Fatalf("crowded role: %d login occurrences, want 200 (100 rows): %s", got, text)
}
if !strings.Contains(text, "Showing the first 100 of 101 administrators") {
t.Fatalf("crowded role: note missing: %s", text)
}
}