package user import ( "net/http" "reflect" "strconv" "strings" "testing" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/compass" ) const ( privilegedMemberForbidden = "This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved." privilegedMemberDeleteForbidden = "Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted." ) // TestAdminUsersTracer drives the Users screen end to end: navigation and // permission gating, the list schema, row states, search, the three filters // and an update. func TestAdminUsersTracer(t *testing.T) { env := newAdminEnv(t) token := env.admin(t, permAccessUsers) stranger := env.admin(t) // Navigation lists the item only for an administrator who may open it. var nav []struct { Code string `json:"code"` Order int `json:"order"` Controller string `json:"controller"` SideMenu []struct { Code string `json:"code"` } `json:"sideMenu"` } adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/navigation"), token, nil), &nav) if len(nav) != 1 || nav[0].Code != "user" || nav[0].Order != 555 || nav[0].Controller != "golem15.user.users" || len(nav[0].SideMenu) != 1 || nav[0].SideMenu[0].Code != "users" { t.Fatalf("navigation = %+v", nav) } nav = nil adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/navigation"), stranger, nil), &nav) if len(nav) != 0 { t.Fatalf("navigation without the permission = %+v", nav) } for _, path := range []string{adminAPI(adminUsersPath), adminAPI(adminUsersPath + "/schema/list"), adminAPI(adminUsersPath + "/schema/form")} { rec := env.expect(t, http.StatusForbidden, http.MethodGet, path, stranger, nil) if code := adminDecode(t, rec).Error.Code; code != "forbidden" { t.Fatalf("%s code = %q", path, code) } } env.expect(t, http.StatusUnauthorized, http.MethodGet, adminAPI(adminUsersPath), "", nil) // The list schema: exactly the ported columns and filters. There is no // login-name column (D-18) and no guest column (D-03). var schema struct { RecordURL string `json:"recordUrl"` Columns []struct { Key string `json:"key"` Invisible bool `json:"invisible"` Searchable bool `json:"searchable"` Type string `json:"type"` } `json:"columns"` Filters []struct { Name string `json:"name"` Type string `json:"type"` Scope string `json:"scope"` Column string `json:"column"` } `json:"filters"` Messages map[string]map[string]string `json:"messages"` } adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/list"), token, nil), &schema) var columns, invisible, searchable, filters []string for _, column := range schema.Columns { columns = append(columns, column.Key) if column.Invisible { invisible = append(invisible, column.Key) } if column.Searchable { searchable = append(searchable, column.Key) } } for _, filter := range schema.Filters { filters = append(filters, filter.Name+":"+filter.Type+":"+filter.Scope+filter.Column) } if want := []string{"id", "name", "surname", "email", "created_at", "last_seen", "created_ip_address", "last_ip_address"}; !reflect.DeepEqual(columns, want) { t.Fatalf("columns = %v, want %v", columns, want) } if want := []string{"id", "surname", "created_ip_address", "last_ip_address"}; !reflect.DeepEqual(invisible, want) { t.Fatalf("invisible columns = %v, want %v", invisible, want) } if want := []string{"name", "surname", "email", "created_ip_address", "last_ip_address"}; !reflect.DeepEqual(searchable, want) { t.Fatalf("searchable columns = %v, want %v", searchable, want) } if want := []string{"groups:scope:filterByGroup", "created_date:daterange:created_at", "activated:switch:is_activated"}; !reflect.DeepEqual(filters, want) { t.Fatalf("filters = %v, want %v", filters, want) } for key, want := range map[string]string{ "empty": "No users match this view. Clear the filters, or add one with New User.", "rowStateDeleted": "Deactivated", "rowStateNegative": "Banned", "rowStateDisabled": "Not activated", } { if got := schema.Messages[key]["other"]; got != want { t.Fatalf("list message %s = %q", key, got) } } // Seed: every user of this test shares one marker in the surname, which // is searchable and not shown. marker := "tracer" + adminStamp() seed := func(label string, mutate func(*models.User)) *models.User { return env.seedUser(t, label, func(u *models.User) { u.Surname = &marker if mutate != nil { mutate(u) } }) } active := seed("active", nil) deactivated := seed("deactivated", nil) banned := seed("banned", nil) inactive := seed("inactive", func(u *models.User) { u.IsActivated = false }) old := seed("old", nil) if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, deactivated.ID).Error; err != nil { t.Fatal(err) } if err := env.gdb.Exec(`INSERT INTO user_throttle (user_id, ip_address, is_banned) VALUES (?, NULL, TRUE)`, banned.ID).Error; err != nil { t.Fatal(err) } if err := env.gdb.Exec(`UPDATE users SET created_at = '2020-06-15 12:00:00+00' WHERE id = ?`, old.ID).Error; err != nil { t.Fatal(err) } group := env.seedGroup(t, "Tracer group "+marker, "tracer-"+marker) env.join(t, active, group) env.join(t, deactivated, group) // Search by the invisible surname finds all five, deactivated included, // with their row states; the rows carry no surname and no secret. page := env.users(t, token, "search="+marker) if page.Total != 5 { t.Fatalf("search total = %d: %s", page.Total, page.Raw) } for _, user := range []*models.User{active, deactivated, banned, inactive, old} { row := page.row(user.ID) if row == nil { t.Fatalf("user %d missing from the list: %s", user.ID, page.Raw) } for _, hidden := range []string{"surname", "created_ip_address", "last_ip_address", "permissions", "is_activated"} { if _, ok := row[hidden]; ok { t.Fatalf("list row carries %s: %v", hidden, row) } } for _, shown := range []string{"id", "name", "email", "created_at", "last_seen"} { if _, ok := row[shown]; !ok { t.Fatalf("list row lacks %s: %v", shown, row) } } } assertNoSecret(t, page.Raw) for id, want := range map[uint][]string{ active.ID: nil, deactivated.ID: {"deleted"}, banned.ID: {"negative"}, inactive.ID: {"disabled"}, } { if got := page.states(id); !reflect.DeepEqual(got, want) { t.Fatalf("row states of %d = %v, want %v", id, got, want) } } // The groups filter keeps the group's members; its choices are the groups. gid := strconv.FormatUint(uint64(group.ID), 10) members := env.users(t, token, "search="+marker+"&filter[groups]="+gid) if members.Total != 2 || !members.has(active.ID) || !members.has(deactivated.ID) { t.Fatalf("group filter = %s", members.Raw) } var options []struct { Value string `json:"value"` Label string `json:"label"` } adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/filters/groups/options"), token, nil), &options) found := false for _, option := range options { if option.Value == gid && option.Label == group.Name { found = true } } if !found { t.Fatalf("group %s missing from the filter options: %+v", gid, options) } env.expect(t, http.StatusForbidden, http.MethodGet, adminAPI(adminUsersPath+"/filters/groups/options"), stranger, nil) // The date range and the activated switch. ranged := env.users(t, token, "search="+marker+"&filter[created_date]=2020-06-01..2020-06-30") if ranged.Total != 1 || !ranged.has(old.ID) { t.Fatalf("date range filter = %s", ranged.Raw) } notActivated := env.users(t, token, "search="+marker+"&filter[activated]=false") if notActivated.Total != 1 || !notActivated.has(inactive.ID) { t.Fatalf("activated=false filter = %s", notActivated.Raw) } activated := env.users(t, token, "search="+marker+"&filter[activated]=true") if activated.Total != 4 || activated.has(inactive.ID) { t.Fatalf("activated=true filter = %s", activated.Raw) } // An update of name and email persists and returns no secret. renamed := "renamed-" + marker + "@example.test" rec := env.expect(t, http.StatusOK, http.MethodPut, userPath(active.ID, ""), token, map[string]any{"name": "Renamed", "email": renamed}) assertNoSecret(t, rec.Body.String()) if got := env.reload(t, active.ID); got.Email != renamed || got.Name == nil || *got.Name != "Renamed" { t.Fatalf("update did not persist: %+v", got) } // The stored hash survives an update that does not name a password. if got := env.reload(t, active.ID); got.Password != active.Password { t.Fatal("an update without a password changed the stored hash") } // A name-only update needs no password (the admin rules replace the // register rules). env.expect(t, http.StatusOK, http.MethodPut, userPath(active.ID, ""), token, map[string]any{"name": "Renamed again", "email": renamed}) // A deactivated user can be shown and updated and stays deactivated. show := env.expect(t, http.StatusOK, http.MethodGet, userPath(deactivated.ID, ""), token, nil) assertNoSecret(t, show.Body.String()) before := env.reload(t, deactivated.ID) env.expect(t, http.StatusOK, http.MethodPut, userPath(deactivated.ID, ""), token, map[string]any{"name": "Still gone", "email": deactivated.Email}) after := env.reload(t, deactivated.ID) if !after.DeletedAt.Valid || !after.DeletedAt.Time.Equal(before.DeletedAt.Time) || after.Name == nil || *after.Name != "Still gone" { t.Fatalf("deactivated user after update = %+v", after) } // Validation failures answer 422 and mark the field. dup := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(banned.ID, ""), token, map[string]any{"name": "Dup", "email": renamed}) if details := adminDecode(t, dup).Error.Details; len(details["email"]) == 0 { t.Fatalf("duplicate email details = %v", details) } bad := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(banned.ID, ""), token, map[string]any{"name": "Bad", "email": "nope"}) if details := adminDecode(t, bad).Error.Details; len(details["email"]) == 0 { t.Fatalf("invalid email details = %v", details) } if got := env.reload(t, banned.ID); got.Email != banned.Email { t.Fatal("a refused update changed the email") } // Protected columns are never written from a body. env.expect(t, http.StatusOK, http.MethodPut, userPath(inactive.ID, ""), token, map[string]any{ "name": "Still inactive", "email": inactive.Email, "is_activated": true, "password": "forged-password-1", "permissions": map[string]int{"x": 1}, }) if got := env.reload(t, inactive.ID); got.IsActivated || got.Password != inactive.Password { t.Fatalf("a body wrote a protected column: %+v", got) } // Writes need the permission too. env.expect(t, http.StatusForbidden, http.MethodPut, userPath(active.ID, ""), stranger, map[string]any{"name": "Nope", "email": renamed}) } // TestAdminPrivilegedMember covers the takeover guard (D-30): the email of a // member of a privileged group, and the delete of such a user, need // golem15.users.manage_privileged_groups on top of // golem15.users.access_users. func TestAdminPrivilegedMember(t *testing.T) { env := newAdminEnv(t) limited := env.admin(t, permAccessUsers) manager := env.admin(t, permAccessUsers, permManagePrivileged) stamp := adminStamp() // The default privileged list is [admin]; several groups may carry the code. adminGroup := env.seedGroup(t, "Admins "+stamp, "admin") ordinaryCode := "ordinary-" + stamp ordinary := env.seedGroup(t, "Ordinary "+stamp, ordinaryCode) uncoded := env.seedGroup(t, "Uncoded "+stamp, "") member := env.seedUser(t, "member", nil) env.join(t, member, adminGroup, ordinary) plain := env.seedUser(t, "plain", nil) env.join(t, plain, ordinary, uncoded) gone := env.seedUser(t, "gone", nil) env.join(t, gone, adminGroup) if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, gone.ID).Error; err != nil { t.Fatal(err) } assertRefused := func(body map[string]any, id uint, fields ...string) { t.Helper() rec := env.expect(t, http.StatusForbidden, http.MethodPut, userPath(id, ""), limited, body) answer := adminDecode(t, rec) if answer.Error.Code != "forbidden" || answer.Error.Message != privilegedMemberForbidden { t.Fatalf("refusal = %s", rec.Body.String()) } if len(answer.Error.Details) != len(fields) { t.Fatalf("refusal details = %v, want fields %v", answer.Error.Details, fields) } for _, field := range fields { if got := answer.Error.Details[field]; len(got) != 1 || got[0] != privilegedMemberForbidden { t.Fatalf("refusal details[%s] = %v", field, got) } } } unchanged := func(original *models.User) { t.Helper() got := env.reload(t, original.ID) if got == nil { t.Fatalf("user %d is gone", original.ID) } if got.Email != original.Email || !reflect.DeepEqual(got.Name, original.Name) || got.Password != original.Password || got.DeletedAt.Valid != original.DeletedAt.Valid { t.Fatalf("user %d changed: %+v", original.ID, got) } } gone = env.reload(t, gone.ID) // An email change on a privileged member is refused as a whole: the name // in the same body is not saved either. assertRefused(map[string]any{"name": "Taken over", "email": "takeover-" + stamp + "@example.test"}, member.ID, "email") unchanged(member) // A deactivated privileged member is protected the same way. assertRefused(map[string]any{"name": "Taken over", "email": "takeover-gone-" + stamp + "@example.test"}, gone.ID, "email") unchanged(gone) // The stored email sent back unchanged with a new name is an ordinary update. env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), limited, map[string]any{"name": "New name", "email": member.Email}) if got := env.reload(t, member.ID); got.Name == nil || *got.Name != "New name" || got.Email != member.Email { t.Fatalf("name-only update of a privileged member = %+v", got) } member = env.reload(t, member.ID) // The delete of a privileged member is refused and nothing changes. for _, target := range []*models.User{member, gone} { rec := env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(target.ID, ""), limited, nil) answer := adminDecode(t, rec) if answer.Error.Code != "forbidden" || answer.Error.Message != privilegedMemberDeleteForbidden || len(answer.Error.Details) != 0 { t.Fatalf("delete refusal = %s", rec.Body.String()) } unchanged(target) if got := env.groupIDs(t, target.ID); len(got) == 0 { t.Fatalf("a refused delete removed the memberships of %d", target.ID) } } // A member of ordinary groups only, and of a group without a code, is // not protected. plainEmail := "plain-new-" + stamp + "@example.test" env.expect(t, http.StatusOK, http.MethodPut, userPath(plain.ID, ""), limited, map[string]any{"name": "Plain", "email": plainEmail}) if got := env.reload(t, plain.ID); got.Email != plainEmail { t.Fatalf("ordinary member email = %q", got.Email) } // With the permission both refused requests succeed. memberEmail := "member-new-" + stamp + "@example.test" env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager, map[string]any{"name": "Managed", "email": memberEmail}) if got := env.reload(t, member.ID); got.Email != memberEmail { t.Fatalf("manager email change = %q", got.Email) } env.expect(t, http.StatusOK, http.MethodDelete, userPath(member.ID, ""), manager, nil) if got := env.reload(t, member.ID); got != nil && !got.DeletedAt.Valid { t.Fatalf("manager delete left the user active: %+v", got) } // The privileged list is config, read per request: with the ordinary // group's code in it the protection moves to that group's member, and the // admin group's member is no longer protected. moved := newAdminEnv(t, func(cfg *compass.Config) { if err := cfg.Set("golem15.user.privileged_groups", []string{ordinaryCode}); err != nil { t.Fatal(err) } }) movedLimited := moved.admin(t, permAccessUsers) rec := moved.expect(t, http.StatusForbidden, http.MethodPut, userPath(plain.ID, ""), movedLimited, map[string]any{"name": "Plain", "email": "plain-moved-" + stamp + "@example.test"}) if answer := adminDecode(t, rec); answer.Error.Message != privilegedMemberForbidden || len(answer.Error.Details["email"]) != 1 { t.Fatalf("moved refusal = %s", rec.Body.String()) } if got := moved.reload(t, plain.ID); got.Email != plainEmail { t.Fatalf("a refused update changed the email to %q", got.Email) } goneEmail := "gone-moved-" + stamp + "@example.test" moved.expect(t, http.StatusOK, http.MethodPut, userPath(gone.ID, ""), movedLimited, map[string]any{"name": "Gone", "email": goneEmail}) if got := moved.reload(t, gone.ID); got.Email != goneEmail || !got.DeletedAt.Valid { t.Fatalf("admin-group member under the moved list = %+v", got) } if strings.Contains(rec.Body.String(), adminHarnessSecret) { t.Fatal("a refusal carries the admin secret") } }