package user import ( "bytes" "encoding/json" "mime/multipart" "net/http" "net/http/httptest" "net/url" "reflect" "strconv" "strings" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/controllers" "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/lagoon/attach" "git.golem15.com/golem15/summercms/modules/pact" ) 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) { // The permission catalog: the four Winter codes default to the developer // role, the privileged-groups code to no role at all. var codes []string for _, permission := range (&Plugin{}).Permissions() { role := "-" if len(permission.Roles) == 1 { role = permission.Roles[0] } codes = append(codes, permission.Code+"@"+role) if permission.Tab != "golem15.user::lang.plugin.tab" || !strings.HasPrefix(permission.Label, "golem15.user::lang.plugin.") { t.Fatalf("permission %+v", permission) } } if want := []string{ "golem15.users.access_users@developer", "golem15.users.access_groups@developer", "golem15.users.access_settings@developer", "golem15.users.impersonate_user@developer", classes.PermissionManagePrivilegedGroups + "@-", }; !reflect.DeepEqual(codes, want) || classes.PermissionManagePrivilegedGroups != permManagePrivileged { t.Fatalf("permissions = %v", codes) } 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, "organisation_id": 987654, "activation_code": "forged", "deleted_at": "2020-01-01T00:00:00Z", "must_change_password": true, "has_self_set_password": false, "last_seen": "2020-01-01T00:00:00Z", "id": 1, }) if got := env.reload(t, inactive.ID); got.IsActivated || got.Password != inactive.Password || got.OrganisationID != nil || got.ActivationCode != nil || got.DeletedAt.Valid || got.MustChangePassword || got.LastSeen != nil || got.ID != inactive.ID { 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) // A password submitted for a privileged member is refused before it is // hashed or any token is invalidated: the old password still signs in. const attempted = "takeover-password-1" assertRefused(map[string]any{"name": "New name", "email": member.Email, "password": attempted, "password_confirmation": attempted}, member.ID, "password") unchanged(member) if got := env.reload(t, member.ID); got.TokensValidAfter != nil { t.Fatalf("a refused password reset stamped tokens_valid_after: %v", got.TokensValidAfter) } if status, _ := env.userLogin(t, member.Email, "correct-horse"); status != http.StatusOK { t.Fatalf("old password after a refused reset = %d", status) } if status, _ := env.userLogin(t, member.Email, attempted); status != http.StatusUnauthorized { t.Fatalf("attempted password after a refused reset = %d", status) } // Email and password in one body: both fields are named. assertRefused(map[string]any{"name": "New name", "email": "both-" + stamp + "@example.test", "password": attempted, "password_confirmation": attempted}, member.ID, "email", "password") unchanged(member) // Validation runs before the hooks, so a mismatching confirmation is a // 422; it changes nothing either. env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(member.ID, ""), limited, map[string]any{"name": "New name", "email": member.Email, "password": attempted, "password_confirmation": "something-else"}) unchanged(member) // An empty password means "unchanged" and is an ordinary update. env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), limited, map[string]any{"name": "Renamed again", "email": member.Email, "password": "", "password_confirmation": ""}) if got := env.reload(t, member.ID); got.Name == nil || *got.Name != "Renamed again" || got.Password != member.Password { t.Fatalf("empty-password update of a privileged member = %+v", got) } member = env.reload(t, member.ID) // Everything else on a privileged member needs only the users // permission: organisation, frontend permissions and the avatar. organisation := models.Organisation{Slug: "guard-" + stamp, Name: "Guard " + stamp} if err := env.gdb.Create(&organisation).Error; err != nil { t.Fatal(err) } permissionCode := "guard." + stamp if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'Guard', 'Guard')`, permissionCode).Error; err != nil { t.Fatal(err) } sessionKey := adminSessionKey(t) if up := env.upload(t, limited, member.ID, "avatar", "member.jpg", jpegBytes(t), sessionKey); up.Code != http.StatusCreated && up.Code != http.StatusOK { t.Fatalf("avatar upload for a privileged member = %d %s", up.Code, up.Body.String()) } body, err := json.Marshal(map[string]any{ "name": "Renamed again", "email": member.Email, "organisation": organisation.ID, "permissions": map[string]int{permissionCode: -1}, }) if err != nil { t.Fatal(err) } if saved := env.send(t, http.MethodPut, userPath(member.ID, ""), limited, body, "application/json", map[string]string{cabana.SessionKeyHeader: sessionKey}); saved.Code != http.StatusOK { t.Fatalf("ordinary changes on a privileged member = %d %s", saved.Code, saved.Body.String()) } got := env.reload(t, member.ID) if got.OrganisationID == nil || *got.OrganisationID != organisation.ID || got.Permissions[permissionCode] != -1 || env.hangers(t, member).Files != 1 { t.Fatalf("ordinary changes on a privileged member were not saved: %+v", got) } // Leave the member without an avatar for the delete scenarios below. if err := env.gdb.Exec(`DELETE FROM system_files WHERE attachment_type = ? AND attachment_id = ?`, member.MorphName(), strconv.FormatUint(uint64(member.ID), 10)).Error; err != nil { t.Fatal(err) } 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) } const managed = "managed-password-1" env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager, map[string]any{"name": "Managed", "email": memberEmail, "password": managed, "password_confirmation": managed}) if got := env.reload(t, member.ID); !bouncer.CheckPassword(got.Password, managed) || got.TokensValidAfter == nil { t.Fatalf("manager password reset = %+v", got) } if status, _ := env.userLogin(t, memberEmail, managed); status != http.StatusOK { t.Fatalf("login with the password the manager set = %d", status) } // The delete is permanent, so the refusal must leave everything that // hangs off the member in place: throttle rows, memberships, the avatar // row and its blob. env.seedThrottle(t, member, "203.0.113.7", false, nil) memberAvatar := env.seedAvatar(t, member) memberHangers := env.hangers(t, member) if memberHangers.Throttle < 1 || memberHangers.Groups != 2 || memberHangers.Files != 1 { t.Fatalf("member seed = %+v", memberHangers) } rec := env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(member.ID, ""), limited, nil) if answer := adminDecode(t, rec); answer.Error.Message != privilegedMemberDeleteForbidden { t.Fatalf("delete refusal = %s", rec.Body.String()) } if env.reload(t, member.ID) == nil || env.hangers(t, member) != memberHangers || !env.blobExists(t, memberAvatar) { t.Fatalf("a refused delete changed the member: %+v", env.hangers(t, member)) } // A bulk delete with one privileged member in the selection is refused as // a whole: the two ordinary users and their rows stay as well. first := env.seedUser(t, "bulk-first", nil) second := env.seedUser(t, "bulk-second", nil) env.join(t, first, ordinary) env.seedThrottle(t, first, "", false, nil) firstAvatar := env.seedAvatar(t, first) firstHangers := env.hangers(t, first) // Ids are processed in ascending order, so the member (the lowest id) is // refused first; the reverse order is covered by the second selection. late := env.seedUser(t, "late-member", nil) env.join(t, late, adminGroup) for _, selection := range [][]*models.User{{member, first, second}, {first, second, late}} { rec = env.bulk(t, limited, "delete", selection...) if rec.Code != http.StatusForbidden || adminDecode(t, rec).Error.Message != privilegedMemberDeleteForbidden { t.Fatalf("bulk delete refusal = %d %s", rec.Code, rec.Body.String()) } for _, user := range selection { if env.reload(t, user.ID) == nil { t.Fatalf("a refused bulk delete removed user %d", user.ID) } } if env.hangers(t, first) != firstHangers || env.hangers(t, member) != memberHangers || !env.blobExists(t, firstAvatar) || !env.blobExists(t, memberAvatar) { t.Fatal("a refused bulk delete removed rows or blobs") } } // Deactivating a privileged member is not a permanent delete and needs // only the users permission; so does restoring them. if rec = env.bulk(t, limited, "deactivate", late); rec.Code != http.StatusOK { t.Fatalf("bulk deactivate of a privileged member = %d %s", rec.Code, rec.Body.String()) } if got := env.reload(t, late.ID); got == nil || !got.DeletedAt.Valid { t.Fatalf("privileged member after deactivate = %+v", got) } if rec = env.bulk(t, limited, "restore", late); rec.Code != http.StatusOK { t.Fatalf("bulk restore of a privileged member = %d %s", rec.Code, rec.Body.String()) } // With the permission the form delete and the bulk delete remove the // member for good, with the full cleanup. env.expect(t, http.StatusOK, http.MethodDelete, userPath(member.ID, ""), manager, nil) if env.reload(t, member.ID) != nil || env.hangers(t, member) != (hangers{}) || env.blobExists(t, memberAvatar) { t.Fatalf("manager delete left something of the member: %+v", env.hangers(t, member)) } if rec = env.bulk(t, manager, "delete", first, second, late); rec.Code != http.StatusOK { t.Fatalf("manager bulk delete = %d %s", rec.Code, rec.Body.String()) } for _, user := range []*models.User{first, second, late} { if env.reload(t, user.ID) != nil || env.hangers(t, user) != (hangers{}) { t.Fatalf("manager bulk delete left user %d: %+v", user.ID, env.hangers(t, user)) } } if env.blobExists(t, firstAvatar) { t.Fatal("manager bulk delete left the avatar blob") } // 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") } } // bulkAnswer is the data of a bulk action answer. type bulkAnswer struct { Message string `json:"message"` Affected int `json:"affected"` } // TestAdminUserActions covers the five bulk actions, the three record // actions and the preview status hint (D-11, D-14), and that none of them // writes users_groups. func TestAdminUserActions(t *testing.T) { env := newAdminEnv(t) token := env.admin(t, permAccessUsers) stranger := env.admin(t) // Nothing but the ported actions is offered: no impersonate, no guest. var schema struct { RecordURL string `json:"recordUrl"` BulkActions []struct { Name string `json:"name"` Label string `json:"label"` Confirm string `json:"confirm"` } `json:"bulkActions"` ToolbarButtons []string `json:"toolbarButtons"` } adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/list"), token, nil), &schema) var bulkNames []string for _, action := range schema.BulkActions { bulkNames = append(bulkNames, action.Name) if action.Label == "" || strings.Contains(action.Label, "::") || strings.Contains(action.Confirm, "::") { t.Fatalf("bulk action copy is not translated: %+v", action) } } if want := []string{"delete", "activate", "deactivate", "restore", "ban", "unban"}; !reflect.DeepEqual(bulkNames, want) { t.Fatalf("bulk actions = %v, want %v", bulkNames, want) } if schema.RecordURL != "golem15/user/users/preview/:id" { t.Fatalf("recordUrl = %q", schema.RecordURL) } var registered []string for _, ctl := range controllers.AdminControllers(func() *backpack.App { return env.app }) { if ctl.ID() != "golem15.user.users" { continue } for _, action := range ctl.(pact.HasAdminRecordActions).AdminRecordActions() { registered = append(registered, action.Name) } } if want := []string{"activate", "unban", "unsuspend"}; !reflect.DeepEqual(registered, want) { t.Fatalf("record actions = %v, want %v", registered, want) } var form struct { Preview *struct { HeaderPartial string `json:"headerPartial"` } `json:"preview"` Fields []struct { Name string `json:"name"` Context any `json:"context"` } `json:"fields"` Messages map[string]map[string]string `json:"messages"` } adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form) if form.Preview == nil || form.Preview.HeaderPartial != "hint" || form.Messages["edit"]["other"] != "Update details" { t.Fatalf("form preview = %+v messages = %v", form.Preview, form.Messages["edit"]) } previewOnly := map[string]bool{} for _, field := range form.Fields { if field.Context == "preview" { previewOnly[field.Name] = true } } if !previewOnly["created_ip_address"] || !previewOnly["last_ip_address"] || len(previewOnly) != 2 { t.Fatalf("preview-only fields = %v", previewOnly) } group := env.seedGroup(t, "Actions "+adminStamp(), "actions-"+adminStamp()) seed := func(label string, mutate func(*models.User)) *models.User { user := env.seedUser(t, label, mutate) env.join(t, user, group) return user } notActive := func(u *models.User) { u.IsActivated = false } idleA, idleB, live := seed("idle-a", notActive), seed("idle-b", notActive), seed("live", nil) code := "pending-code" if err := env.gdb.Exec(`UPDATE users SET activation_code = ?, activation_code_issued_at = NOW() WHERE id = ?`, code, idleA.ID).Error; err != nil { t.Fatal(err) } everyone := []*models.User{idleA, idleB, live} memberships := func() map[uint][]uint { out := map[uint][]uint{} for _, user := range everyone { out[user.ID] = env.groupIDs(t, user.ID) } return out } before := memberships() untouched := func(step string) { t.Helper() if got := memberships(); !reflect.DeepEqual(got, before) { t.Fatalf("%s changed users_groups: %v, want %v", step, got, before) } } run := func(action string, users ...*models.User) bulkAnswer { t.Helper() rec := env.bulk(t, token, action, users...) if rec.Code != http.StatusOK { t.Fatalf("bulk %s = %d %s", action, rec.Code, rec.Body.String()) } var answer bulkAnswer adminInto(t, rec, &answer) untouched("bulk " + action) return answer } // Activate skips the user who is already active and reports the count // without the fixed success phrase, which would overstate. if answer := run("activate", everyone...); answer.Affected != 2 || answer.Message != "" { t.Fatalf("bulk activate = %+v", answer) } for _, user := range everyone { got := env.reload(t, user.ID) if !got.IsActivated || (user.ID != live.ID && got.ActivatedAt == nil) || got.ActivationCode != nil { t.Fatalf("user %d after bulk activate = %+v", user.ID, got) } } if answer := run("activate", everyone...); answer.Affected != 0 || answer.Message != "" { t.Fatalf("second bulk activate = %+v", answer) } // When every selected user changes, the fixed phrase is the message. fresh := seed("fresh", notActive) everyone = append(everyone, fresh) before = memberships() if answer := run("activate", fresh); answer.Affected != 1 || answer.Message != "Successfully activated the selected users." { t.Fatalf("full bulk activate = %+v", answer) } // Deactivate is the soft delete; the users stay listed as deleted. if answer := run("deactivate", idleA, idleB); answer.Affected != 2 || answer.Message != "Successfully deactivated the selected users." { t.Fatalf("bulk deactivate = %+v", answer) } page := env.users(t, token, "search="+idleA.Email) if !reflect.DeepEqual(page.states(idleA.ID), []string{"deleted"}) { t.Fatalf("deactivated row = %s", page.Raw) } if got := env.reload(t, idleB.ID); !got.DeletedAt.Valid { t.Fatal("bulk deactivate did not set deleted_at") } if answer := run("deactivate", idleA, live); answer.Affected != 1 || answer.Message != "" { t.Fatalf("mixed bulk deactivate = %+v", answer) } // Restore brings them back. if answer := run("restore", idleA, idleB, live); answer.Affected != 3 || answer.Message != "Successfully restored the selected users." { t.Fatalf("bulk restore = %+v", answer) } for _, user := range []*models.User{idleA, idleB, live} { if got := env.reload(t, user.ID); got.DeletedAt.Valid { t.Fatalf("user %d is still deactivated", user.ID) } } // Activating a deactivated, not activated user restores them as well. sleeper := seed("sleeper", notActive) everyone = append(everyone, sleeper) before = memberships() run("deactivate", sleeper) if answer := run("activate", sleeper); answer.Affected != 1 { t.Fatalf("activate of a deactivated user = %+v", answer) } if got := env.reload(t, sleeper.ID); !got.IsActivated || got.DeletedAt.Valid { t.Fatalf("sleeper after activate = %+v", got) } // Ban refuses the login from any address and marks the row; unban lifts it. if status, _ := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusOK { t.Fatalf("login before the ban = %d", status) } if answer := run("ban", live); answer.Affected != 1 || answer.Message != "Successfully banned the selected users." { t.Fatalf("bulk ban = %+v", answer) } for range 2 { if status, token := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusUnauthorized || token != "" { t.Fatalf("banned login = %d", status) } } page = env.users(t, token, "search="+live.Email) if !reflect.DeepEqual(page.states(live.ID), []string{"negative"}) { t.Fatalf("banned row = %s", page.Raw) } if answer := run("ban", live, idleA); answer.Affected != 1 || answer.Message != "" { t.Fatalf("second bulk ban = %+v", answer) } if answer := run("unban", live, idleA, idleB); answer.Affected != 2 || answer.Message != "" { t.Fatalf("bulk unban = %+v", answer) } if status, _ := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusOK { t.Fatalf("login after the unban = %d", status) } // Record actions are offered only while they apply. pending := seed("pending", notActive) everyone = append(everyone, pending) before = memberships() if got := env.offered(t, token, pending.ID); !reflect.DeepEqual(got, []string{"activate"}) { t.Fatalf("offered for a not activated user = %v", got) } if got := env.offered(t, token, live.ID); len(got) != 0 { t.Fatalf("offered for an ordinary user = %v", got) } act := func(status int, action string, user *models.User) string { t.Helper() rec := env.expect(t, status, http.MethodPost, userPath(user.ID, "/actions/"+action), token, map[string]any{}) untouched("record " + action) if status != http.StatusOK { return "" } var answer struct { Message string `json:"message"` } adminInto(t, rec, &answer) return answer.Message } if message := act(http.StatusOK, "activate", pending); message != "User has been activated" { t.Fatalf("activate message = %q", message) } if got := env.reload(t, pending.ID); !got.IsActivated || got.ActivatedAt == nil { t.Fatalf("pending after activate = %+v", got) } act(http.StatusConflict, "activate", pending) act(http.StatusConflict, "unban", pending) act(http.StatusConflict, "unsuspend", pending) run("ban", pending) if got := env.offered(t, token, pending.ID); !reflect.DeepEqual(got, []string{"unban"}) { t.Fatalf("offered for a banned user = %v", got) } if message := act(http.StatusOK, "unban", pending); message != "User has been unbanned" { t.Fatalf("unban message = %q", message) } if got := env.offered(t, token, pending.ID); len(got) != 0 { t.Fatalf("offered after unban = %v", got) } // Suspended means suspended inside the window; an expired suspension // offers nothing and is not lifted by reading it. now, longAgo := time.Now(), time.Now().Add(-2*time.Hour) suspended := seed("suspended", nil) expired := seed("expired", nil) everyone = append(everyone, suspended, expired) before = memberships() env.seedThrottle(t, suspended, "203.0.113.9", false, &now) env.seedThrottle(t, expired, "203.0.113.9", false, &longAgo) if got := env.offered(t, token, suspended.ID); !reflect.DeepEqual(got, []string{"unsuspend"}) { t.Fatalf("offered for a suspended user = %v", got) } if got := env.offered(t, token, expired.ID); len(got) != 0 { t.Fatalf("offered for an expired suspension = %v", got) } var stillFlagged int64 if err := env.gdb.Raw(`SELECT COUNT(*) FROM user_throttle WHERE user_id = ? AND is_suspended`, expired.ID).Scan(&stillFlagged).Error; err != nil || stillFlagged != 1 { t.Fatalf("reading an expired suspension changed it: %d %v", stillFlagged, err) } if message := act(http.StatusOK, "unsuspend", suspended); message != "User has been unsuspended." { t.Fatalf("unsuspend message = %q", message) } var row models.Throttle if err := env.gdb.Where("user_id = ?", suspended.ID).Take(&row).Error; err != nil || row.IsSuspended || row.SuspendedAt != nil || row.Attempts != 0 { t.Fatalf("throttle row after unsuspend = %+v err=%v", row, err) } act(http.StatusConflict, "unsuspend", suspended) // The preview hint: one callout, by precedence banned, deactivated, not // activated, suspended. hint := func(user *models.User) string { t.Helper() rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/partials/hint?id="+strconv.FormatUint(uint64(user.ID), 10)), token, nil) return rec.Body.String() } worst := seed("worst", notActive) everyone = append(everyone, worst) before = memberships() env.seedThrottle(t, worst, "203.0.113.9", false, &now) if body := hint(worst); !strings.Contains(body, "User not activated!") || !strings.Contains(body, "summer-callout--warning") || strings.Contains(body, "suspended") { t.Fatalf("not activated beats suspended: %s", body) } run("deactivate", worst) if body := hint(worst); !strings.Contains(body, "User has deactivated their account") || !strings.Contains(body, "summer-callout--danger") || strings.Contains(body, "not activated") { t.Fatalf("deactivated beats not activated: %s", body) } run("ban", worst) if body := hint(worst); !strings.Contains(body, "User has been banned") || strings.Contains(body, "deactivated their account") { t.Fatalf("banned beats deactivated: %s", body) } lifted := seed("lifted", nil) everyone = append(everyone, lifted) before = memberships() env.seedThrottle(t, lifted, "203.0.113.9", false, &now) if body := hint(lifted); !strings.Contains(body, "User is temporarily suspended") || !strings.Contains(body, "summer-callout--warning") { t.Fatalf("suspended hint: %s", body) } if body := hint(live); strings.Contains(body, "summer-callout") { t.Fatalf("hint for an ordinary user: %s", body) } // Every action needs the users permission. if rec := env.bulk(t, stranger, "ban", live); rec.Code != http.StatusForbidden { t.Fatalf("bulk ban without the permission = %d", rec.Code) } env.expect(t, http.StatusForbidden, http.MethodPost, userPath(lifted.ID, "/actions/unsuspend"), stranger, map[string]any{}) env.expect(t, http.StatusForbidden, http.MethodGet, adminAPI(adminUsersPath+"/partials/hint?id=1"), stranger, nil) if status, _ := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusOK { t.Fatal("a refused ban changed the user") } // An undeclared action name is not run. if rec := env.bulk(t, token, "impersonate", live); rec.Code == http.StatusOK { t.Fatalf("an unknown bulk action answered %d", rec.Code) } if rec := env.call(t, http.MethodPost, userPath(live.ID, "/actions/impersonate"), token, map[string]any{}); rec.Code == http.StatusOK { t.Fatalf("an unknown record action answered %d", rec.Code) } } // TestAdminUserForceDelete covers the permanent delete (D-13): the form // button and the bulk delete remove the user row with its throttle rows, // group memberships and attachments, and the blobs after the commit. func TestAdminUserForceDelete(t *testing.T) { env := newAdminEnv(t) token := env.admin(t, permAccessUsers) stranger := env.admin(t) group := env.seedGroup(t, "Delete "+adminStamp(), "delete-"+adminStamp()) loaded := func(label string) (*models.User, string) { user := env.seedUser(t, label, nil) env.join(t, user, group) // The throttle foreign key has no ON DELETE: without the cleanup the // delete would fail on it. env.seedThrottle(t, user, "203.0.113.20", false, nil) env.seedThrottle(t, user, "", true, nil) return user, env.seedAvatar(t, user) } assertGone := func(user *models.User, key string) { t.Helper() if env.reload(t, user.ID) != nil { t.Fatalf("user %d still exists", user.ID) } if left := env.hangers(t, user); left != (hangers{}) { t.Fatalf("user %d left orphans: %+v", user.ID, left) } if env.blobExists(t, key) { t.Fatalf("user %d left its avatar blob", user.ID) } } // Without the permission nothing is deleted. victim, victimKey := loaded("victim") env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(victim.ID, ""), stranger, nil) if rec := env.bulk(t, stranger, "delete", victim); rec.Code != http.StatusForbidden { t.Fatalf("bulk delete without the permission = %d", rec.Code) } if env.reload(t, victim.ID) == nil || env.hangers(t, victim) != (hangers{Throttle: 2, Groups: 1, Files: 1}) || !env.blobExists(t, victimKey) { t.Fatalf("a refused delete changed the user: %+v", env.hangers(t, victim)) } // The form delete. env.expect(t, http.StatusOK, http.MethodDelete, userPath(victim.ID, ""), token, nil) assertGone(victim, victimKey) env.expect(t, http.StatusNotFound, http.MethodGet, userPath(victim.ID, ""), token, nil) // The bulk delete, with a deactivated user in the selection. one, oneKey := loaded("bulk-one") two, twoKey := loaded("bulk-two") bystander, bystanderKey := loaded("bystander") if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, two.ID).Error; err != nil { t.Fatal(err) } rec := env.bulk(t, token, "delete", one, two) if rec.Code != http.StatusOK { t.Fatalf("bulk delete = %d %s", rec.Code, rec.Body.String()) } var deleted struct { Deleted int `json:"deleted"` } adminInto(t, rec, &deleted) if deleted.Deleted != 2 { t.Fatalf("bulk delete count = %d", deleted.Deleted) } assertGone(one, oneKey) assertGone(two, twoKey) if env.reload(t, bystander.ID) == nil || env.hangers(t, bystander) != (hangers{Throttle: 2, Groups: 1, Files: 1}) || !env.blobExists(t, bystanderKey) { t.Fatal("the bulk delete touched a user outside the selection") } // The group itself is untouched. var groups int64 if err := env.gdb.Raw(`SELECT COUNT(*) FROM user_groups WHERE id = ?`, group.ID).Scan(&groups).Error; err != nil || groups != 1 { t.Fatalf("the delete removed the group: %d %v", groups, err) } } // createdID reads the id of a create or update answer. func createdID(t *testing.T, rec *httptest.ResponseRecorder) uint { t.Helper() var data struct { ID uint `json:"id"` } adminInto(t, rec, &data) if data.ID == 0 { t.Fatalf("no id in %s", rec.Body.String()) } return data.ID } // TestAdminUserPassword covers the password field (D-19): required and // confirmed on create, optional on update, hashed, never returned, and an // admin reset ends the user's sessions. func TestAdminUserPassword(t *testing.T) { env := newAdminEnv(t) token := env.admin(t, permAccessUsers) stamp := adminStamp() const first, second = "first-password-1", "second-password-2" email := "password-" + stamp + "@example.test" // Create: the hash verifies, the user is not activated, nothing leaks. created := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{ "name": "Password", "surname": "pw" + stamp, "email": email, "password": first, "password_confirmation": first, "send_invite": false, }) assertNoSecret(t, created.Body.String(), first) id := createdID(t, created) user := env.reload(t, id) if !bouncer.CheckPassword(user.Password, first) || user.Password == first { t.Fatal("the stored password is not a hash of the submitted one") } if user.IsActivated || user.ActivatedAt != nil || !user.HasSelfSetPassword || user.TokensValidAfter != nil || user.DeletedAt.Valid { t.Fatalf("created user = %+v", user) } for _, rec := range []*httptest.ResponseRecorder{ env.expect(t, http.StatusOK, http.MethodGet, userPath(id, ""), token, nil), env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"?search=pw"+stamp), token, nil), } { assertNoSecret(t, rec.Body.String(), first, user.Password) } // A request cannot turn the activation on, and cannot store a hash directly. if status, _ := env.userLogin(t, email, first); status != http.StatusOK { t.Fatalf("login with the created password = %d", status) } // Create failures answer 422 on the field and create nothing. failures := []struct { name string body map[string]any field string }{ {"confirmation mismatch", map[string]any{"password": first, "password_confirmation": second}, "password"}, {"missing confirmation", map[string]any{"password": first}, "password"}, {"missing password", map[string]any{}, "password"}, {"short password", map[string]any{"password": "short", "password_confirmation": "short"}, "password"}, {"duplicate email", map[string]any{"email": email, "password": first, "password_confirmation": first}, "email"}, } for i, failure := range failures { body := map[string]any{"name": "Failure", "email": "failure-" + stamp + strconv.Itoa(i) + "@example.test"} for key, value := range failure.body { body[key] = value } rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminUsersPath), token, body) if details := adminDecode(t, rec).Error.Details; len(details[failure.field]) == 0 { t.Fatalf("%s details = %v", failure.name, details) } if strings.Contains(rec.Body.String(), first) { t.Fatalf("%s echoes the password: %s", failure.name, rec.Body.String()) } var n int64 if err := env.gdb.Raw(`SELECT COUNT(*) FROM users WHERE email = ?`, body["email"]).Scan(&n).Error; err != nil || (n != 0 && failure.field != "email") { t.Fatalf("%s created a user: %d %v", failure.name, n, err) } } // The email of a deactivated user is taken as well: a 422, not a 500 // from the unique constraint. gone := env.seedUser(t, "gone", nil) if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, gone.ID).Error; err != nil { t.Fatal(err) } rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{"name": "Clash", "email": gone.Email, "password": first, "password_confirmation": first}) if details := adminDecode(t, rec).Error.Details; len(details["email"]) != 1 || details["email"][0] != "The email has already been taken." { t.Fatalf("deactivated email clash = %s", rec.Body.String()) } rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(id, ""), token, map[string]any{"name": "Password", "email": gone.Email}) if details := adminDecode(t, rec).Error.Details; len(details["email"]) != 1 { t.Fatalf("deactivated email clash on update = %s", rec.Body.String()) } // Update without a password: only the name changes (RESEARCH Pitfall 1). for _, body := range []map[string]any{ {"name": "Renamed", "email": email}, {"name": "Renamed", "email": email, "password": "", "password_confirmation": ""}, } { updated := env.expect(t, http.StatusOK, http.MethodPut, userPath(id, ""), token, body) assertNoSecret(t, updated.Body.String(), first, user.Password) if got := env.reload(t, id); got.Password != user.Password || got.TokensValidAfter != nil || got.Name == nil || *got.Name != "Renamed" { t.Fatalf("an update without a password changed the credentials: %+v", got) } } // A mismatching confirmation on update is refused and changes nothing. rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(id, ""), token, map[string]any{"name": "Renamed", "email": email, "password": second, "password_confirmation": first}) if details := adminDecode(t, rec).Error.Details; len(details["password"]) == 0 { t.Fatalf("update mismatch details = %v", details) } if got := env.reload(t, id); got.Password != user.Password { t.Fatal("a refused password reset changed the hash") } // Update with a password: the hash changes, and tokens issued before the // reset stop working. _, userToken := env.userLogin(t, email, first) if fetch := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil); fetch.Code != http.StatusOK { t.Fatalf("fetch before the reset = %d %s", fetch.Code, fetch.Body.String()) } time.Sleep(1100 * time.Millisecond) // token timestamps have one-second resolution reset := env.expect(t, http.StatusOK, http.MethodPut, userPath(id, ""), token, map[string]any{"name": "Renamed", "email": email, "password": second, "password_confirmation": second}) assertNoSecret(t, reset.Body.String(), second) after := env.reload(t, id) if !bouncer.CheckPassword(after.Password, second) || bouncer.CheckPassword(after.Password, first) || after.TokensValidAfter == nil { t.Fatalf("user after the reset = %+v", after) } if fetch := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil); fetch.Code != http.StatusUnauthorized { t.Fatalf("a token from before the reset still works: %d", fetch.Code) } if status, _ := env.userLogin(t, email, first); status != http.StatusUnauthorized { t.Fatalf("the old password still signs in: %d", status) } if status, _ := env.userLogin(t, email, second); status != http.StatusOK { t.Fatalf("the new password does not sign in: %d", status) } // The reset did not activate the user or touch anything else. if after.IsActivated || after.Email != email { t.Fatalf("the reset changed more than the password: %+v", after) } } // TestAdminUserInvite covers send_invite (D-19): exactly one invitation // after the create commits, with a link that activates the user. func TestAdminUserInvite(t *testing.T) { env := newAdminEnv(t) token := env.admin(t, permAccessUsers) stamp := adminStamp() const password = "invite-password-1" create := func(status int, email string, extra map[string]any) *httptest.ResponseRecorder { t.Helper() body := map[string]any{"name": "Invited", "email": email, "password": password, "password_confirmation": password} for key, value := range extra { body[key] = value } return env.expect(t, status, http.MethodPost, adminAPI(adminUsersPath), token, body) } // The form schema turns the checkbox on by default, on create only. var form struct { Fields []struct { Name string `json:"name"` Type string `json:"type"` Default any `json:"default"` Context any `json:"context"` } `json:"fields"` } adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form) seen := false for _, field := range form.Fields { if field.Name == "send_invite" { seen = field.Type == "checkbox" && field.Default == true && field.Context == "create" } } if !seen { t.Fatalf("send_invite field = %+v", form.Fields) } invited := "invited-" + stamp + "@example.test" id := createdID(t, create(http.StatusCreated, invited, map[string]any{"send_invite": true})) mails := env.mails(invited) if len(mails) != 1 { t.Fatalf("invitations sent = %d, want exactly 1: %+v", len(mails), mails) } mail := mails[0] if mail.Subject != "An account has been created for you" || !strings.Contains(mail.Text, "Invited") || !strings.Contains(mail.Text, invited) || !strings.Contains(mail.Text, "72 hours") || strings.Contains(mail.Text, password) { t.Fatalf("invitation = %+v", mail) } user := env.reload(t, id) if user.IsActivated || user.ActivationCode == nil || user.ActivationCodeIssuedAt == nil { t.Fatalf("invited user = %+v", user) } // The link carries {id}!{code} and activates through the user API. start := strings.Index(mail.Text, "https://app.example.test/activate?code=") if start < 0 { t.Fatalf("no activation link in %q", mail.Text) } link := mail.Text[start:] link = link[:strings.IndexAny(link, ")\n ")] parsed, err := url.Parse(link) if err != nil { t.Fatal(err) } packed := parsed.Query().Get("code") if packed != strconv.FormatUint(uint64(id), 10)+"!"+*user.ActivationCode { t.Fatalf("link code = %q", packed) } activated := env.call(t, http.MethodPost, "/_user/api/v1/activate-by-code", "", map[string]string{"code": packed}) if activated.Code != http.StatusOK { t.Fatalf("activate-by-code = %d %s", activated.Code, activated.Body.String()) } if got := env.reload(t, id); !got.IsActivated || got.ActivationCode != nil { t.Fatalf("user after the invitation link = %+v", got) } // No invitation without the checkbox, whether it is off or not sent. off := "invite-off-" + stamp + "@example.test" offID := createdID(t, create(http.StatusCreated, off, map[string]any{"send_invite": false})) absent := "invite-absent-" + stamp + "@example.test" create(http.StatusCreated, absent, nil) if n := len(env.mails(off)) + len(env.mails(absent)); n != 0 { t.Fatalf("invitations without the checkbox = %d", n) } if got := env.reload(t, offID); got.ActivationCode != nil { t.Fatal("an activation code was issued without an invitation") } // A create that fails validation sends nothing. failed := "invite-failed-" + stamp + "@example.test" create(http.StatusUnprocessableEntity, failed, map[string]any{"send_invite": true, "password_confirmation": "does-not-match"}) create(http.StatusUnprocessableEntity, invited, map[string]any{"send_invite": true}) if n := len(env.mails(failed)); n != 0 || len(env.mails(invited)) != 1 { t.Fatalf("a failed create sent an invitation: %d, %d", n, len(env.mails(invited))) } // send_invite is a create-only field: an update that carries it sends nothing. env.expect(t, http.StatusOK, http.MethodPut, userPath(offID, ""), token, map[string]any{"name": "Invited", "email": off, "send_invite": true}) if n := len(env.mails(off)); n != 0 { t.Fatalf("an update sent an invitation: %d", n) } } // TestAdminAvatarSharedWithAPI covers D-20: the admin form's avatar field // and the user API read and write the same attachment. func TestAdminAvatarSharedWithAPI(t *testing.T) { env := newAdminEnv(t) token := env.admin(t, permAccessUsers) user := env.seedUser(t, "avatar", nil) _, userToken := env.userLogin(t, user.Email, "correct-horse") payload := func() map[string]any { t.Helper() rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil) if rec.Code != http.StatusOK { t.Fatalf("fetch = %d %s", rec.Code, rec.Body.String()) } var body struct { User map[string]any `json:"user"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } return body.User } adminFiles := func() []cabana.FileItem { t.Helper() var files []cabana.FileItem adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, "/files/avatar"), token, nil), &files) return files } if got := payload(); got["has_avatar"] != false || len(adminFiles()) != 0 { t.Fatalf("a new user has an avatar: %v", got) } // The form schema describes the field as PHP does: an image, 260 by 260. var form struct { Fields []struct { Name string `json:"name"` Type string `json:"type"` Mode string `json:"mode"` ImageWidth int `json:"imageWidth"` ImageHeight int `json:"imageHeight"` } `json:"fields"` } adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form) described := false for _, field := range form.Fields { if field.Name == "avatar" { described = field.Type == "fileupload" && field.Mode == "image" && field.ImageWidth == 260 && field.ImageHeight == 260 } } if !described { t.Fatalf("avatar field = %+v", form.Fields) } // Admin to app: an upload attached by the form save shows in the payload. key := adminSessionKey(t) if up := env.upload(t, token, user.ID, "avatar", "from-admin.jpg", jpegBytes(t), key); up.Code != http.StatusCreated && up.Code != http.StatusOK { t.Fatalf("admin upload = %d %s", up.Code, up.Body.String()) } if got := payload(); got["has_avatar"] != false { t.Fatal("a pending upload already shows in the user payload") } body, err := json.Marshal(map[string]any{"name": "avatar", "email": user.Email}) if err != nil { t.Fatal(err) } if saved := env.send(t, http.MethodPut, userPath(user.ID, ""), token, body, "application/json", map[string]string{cabana.SessionKeyHeader: key}); saved.Code != http.StatusOK { t.Fatalf("save with the upload = %d %s", saved.Code, saved.Body.String()) } got := payload() avatarURL, _ := got["avatar_url"].(string) if got["has_avatar"] != true || avatarURL == "" || strings.Contains(avatarURL, "gravatar") { t.Fatalf("user payload after the admin upload = %v", got) } var row attach.File if err := env.gdb.Where("attachment_type = ? AND attachment_id = ? AND field = ?", user.MorphName(), strconv.FormatUint(uint64(user.ID), 10), "avatar").Take(&row).Error; err != nil { t.Fatal(err) } if got["avatar"] != row.DiskName || !row.Public() || row.FileName != "from-admin.jpg" { t.Fatalf("avatar row = %+v payload avatar = %v", row, got["avatar"]) } if files := adminFiles(); len(files) != 1 || files[0].ID != row.ID || files[0].Pending { t.Fatalf("admin file list = %+v", files) } // App to admin: an avatar uploaded through the user API replaces it and // shows in the admin field. var buf bytes.Buffer mw := multipart.NewWriter(&buf) part, err := mw.CreateFormFile("avatar", "from-app.jpg") if err != nil { t.Fatal(err) } if _, err := part.Write(jpegBytes(t)); err != nil { t.Fatal(err) } if err := mw.Close(); err != nil { t.Fatal(err) } if up := env.send(t, http.MethodPost, "/_user/api/v1/avatar", userToken, buf.Bytes(), mw.FormDataContentType(), nil); up.Code != http.StatusOK { t.Fatalf("user API upload = %d %s", up.Code, up.Body.String()) } files := adminFiles() if len(files) != 1 || files[0].FileName != "from-app.jpg" || files[0].ID == row.ID { t.Fatalf("admin file list after the user API upload = %+v", files) } if got := payload(); got["has_avatar"] != true { t.Fatalf("user payload after the user API upload = %v", got) } } // TestAdminUserFormFields covers the organisation picker (D-22) and the // frontend permission editor (D-15, D-16) of the user form. func TestAdminUserFormFields(t *testing.T) { env := newAdminEnv(t) token := env.admin(t, permAccessUsers) stamp := adminStamp() user := env.seedUser(t, "fields", nil) _, userToken := env.userLogin(t, user.Email, "correct-horse") base := map[string]any{"name": "fields", "email": user.Email} save := func(status int, extra map[string]any) *httptest.ResponseRecorder { t.Helper() body := map[string]any{} for key, value := range base { body[key] = value } for key, value := range extra { body[key] = value } return env.expect(t, status, http.MethodPut, userPath(user.ID, ""), token, body) } // Organisation: a belongsTo picker that writes organisation_id. first := models.Organisation{Slug: "first-" + stamp, Name: "First " + stamp} second := models.Organisation{Slug: "second-" + stamp, Name: "Second " + stamp} for _, organisation := range []*models.Organisation{&first, &second} { if err := env.gdb.Create(organisation).Error; err != nil { t.Fatal(err) } } var options []struct { Value uint `json:"value"` Label string `json:"label"` } adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/fields/organisation/options?search="+stamp), token, nil), &options) if len(options) != 2 { t.Fatalf("organisation options = %+v", options) } saved := save(http.StatusOK, map[string]any{"organisation": first.ID}) if got := env.reload(t, user.ID); got.OrganisationID == nil || *got.OrganisationID != first.ID { t.Fatalf("organisation_id after the save = %v", got.OrganisationID) } if !strings.Contains(saved.Body.String(), first.Name) { t.Fatalf("the record answer lacks the organisation label: %s", saved.Body.String()) } save(http.StatusOK, map[string]any{"organisation": second.ID}) if got := env.reload(t, user.ID); got.OrganisationID == nil || *got.OrganisationID != second.ID { t.Fatalf("organisation_id after the change = %v", got.OrganisationID) } // An id that is not an organisation is refused; the plain column name is ignored. save(http.StatusUnprocessableEntity, map[string]any{"organisation": 987654321}) save(http.StatusOK, map[string]any{"organisation_id": first.ID}) if got := env.reload(t, user.ID); got.OrganisationID == nil || *got.OrganisationID != second.ID { t.Fatalf("a plain organisation_id key was written: %v", got.OrganisationID) } save(http.StatusOK, map[string]any{"organisation": nil}) if got := env.reload(t, user.ID); got.OrganisationID != nil { t.Fatalf("organisation_id after clearing = %v", *got.OrganisationID) } // Permissions: the options are the table's rows, grouped by tab. read, write, admin := "forum."+stamp+".read", "forum."+stamp+".write", "shop."+stamp+".admin" if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab, comment) VALUES (?, 'Write', ?, 'May write posts'), (?, 'Read', ?, NULL), (?, 'Administer', ?, NULL)`, write, "Forum "+stamp, read, "Forum "+stamp, admin, "Shop "+stamp).Error; err != nil { t.Fatal(err) } var form struct { Fields []struct { Name string `json:"name"` Type string `json:"type"` Mode string `json:"mode"` Tab string `json:"tab"` Context any `json:"context"` Options []struct { Code string `json:"code"` Label string `json:"label"` Tab string `json:"tab"` Comment string `json:"comment"` Locked bool `json:"locked"` } `json:"permissionOptions"` } `json:"fields"` } adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form) var offered []string var names []string for _, field := range form.Fields { names = append(names, field.Name) if field.Name != "permissions" { continue } if field.Type != "permissioneditor" || field.Mode != "radio" || field.Tab != "Permissions" || field.Context != "update" { t.Fatalf("permissions field = %+v", field) } for _, option := range field.Options { if !strings.Contains(option.Code, stamp) { continue } offered = append(offered, option.Tab+"|"+option.Code+"|"+option.Label+"|"+option.Comment) if option.Locked { t.Fatalf("a frontend permission is locked: %+v", option) } } } if want := []string{ "Forum " + stamp + "|" + read + "|Read|", "Forum " + stamp + "|" + write + "|Write|May write posts", "Shop " + stamp + "|" + admin + "|Administer|", }; !reflect.DeepEqual(offered, want) { t.Fatalf("permission options = %v, want %v", offered, want) } // The form has no login-name, block-mail, guest or groups field (D-03, // D-18, D-21; groups arrive with their guard). if want := []string{"name", "surname", "email", "send_invite", "password", "password_confirmation", "organisation", "avatar", "created_ip_address", "last_ip_address", "permissions"}; !reflect.DeepEqual(names, want) { t.Fatalf("form fields = %v, want %v", names, want) } // Allow and deny are stored as the JSON object PHP writes; inherit is no entry. save(http.StatusOK, map[string]any{"permissions": map[string]int{read: 1, write: -1, admin: 0}}) var stored string if err := env.gdb.Raw(`SELECT permissions FROM users WHERE id = ?`, user.ID).Scan(&stored).Error; err != nil { t.Fatal(err) } var decoded map[string]int if err := json.Unmarshal([]byte(stored), &decoded); err != nil || !reflect.DeepEqual(decoded, map[string]int{read: 1, write: -1}) { t.Fatalf("stored permissions = %q (%v)", stored, err) } var record struct { Permissions map[string]int `json:"permissions"` } adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, ""), token, nil), &record) if !reflect.DeepEqual(record.Permissions, map[string]int{read: 1, write: -1}) { t.Fatalf("record permissions = %v", record.Permissions) } // The resolver answers from what the editor stored. for code, want := range map[string]bool{read: true, write: false, admin: false, "forum." + stamp + ".*": true} { held, err := classes.UserHasPermission(t.Context(), env.gdb, user.ID, code) if err != nil || held != want { t.Fatalf("UserHasPermission(%s) = %v %v, want %v", code, held, err, want) } } // An unknown code and a value outside the set are refused, and nothing changes. for _, bad := range []map[string]any{ {"permissions": map[string]int{"unknown." + stamp: 1}}, {"permissions": map[string]int{read: 2}}, {"permissions": "all"}, } { rec := save(http.StatusUnprocessableEntity, bad) if details := adminDecode(t, rec).Error.Details; len(details["permissions"]) == 0 { t.Fatalf("permission refusal details = %v", details) } } if got := env.reload(t, user.ID); !reflect.DeepEqual(got.Permissions, models.PermissionSet{read: 1, write: -1}) { t.Fatalf("a refused save changed the permissions: %v", got.Permissions) } // A save that does not send the field leaves the stored set alone; an // empty object clears it to NULL. save(http.StatusOK, nil) if got := env.reload(t, user.ID); len(got.Permissions) != 2 { t.Fatalf("a save without the field changed the permissions: %v", got.Permissions) } save(http.StatusOK, map[string]any{"permissions": map[string]int{}}) var isNull bool if err := env.gdb.Raw(`SELECT permissions IS NULL FROM users WHERE id = ?`, user.ID).Scan(&isNull).Error; err != nil || !isNull { t.Fatalf("cleared permissions are not NULL: %v", err) } // None of it reaches the user API payload: permissions stays null and // groups an empty list (D-08). save(http.StatusOK, map[string]any{"permissions": map[string]int{read: 1}, "organisation": first.ID}) fetch := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil) if fetch.Code != http.StatusOK { t.Fatalf("fetch = %d %s", fetch.Code, fetch.Body.String()) } var body struct { User map[string]any `json:"user"` } if err := json.Unmarshal(fetch.Body.Bytes(), &body); err != nil { t.Fatal(err) } if value, ok := body.User["permissions"]; !ok || value != nil { t.Fatalf("user payload permissions = %v (%v)", value, ok) } if groups, ok := body.User["groups"].([]any); !ok || len(groups) != 0 { t.Fatalf("user payload groups = %v", body.User["groups"]) } for _, leaked := range []string{"last_seen", "created_at", "updated_at", "organisation", "organisation_id", read} { if strings.Contains(fetch.Body.String(), `"`+leaked+`"`) { t.Fatalf("user payload carries %s: %s", leaked, fetch.Body.String()) } } }