- each controller driven directly on records of another type, an application that is not booted, one without a database and one whose database fails: a guard that cannot read answers a plain error, never a pass - D-30: a superuser passes the takeover guard; an unreadable membership is the opaque 500 with nothing changed - unknown partials, undeclared filters and sorts, options of fields that have none, search and sort on the three lists - a name-only update of a privileged group, validation before the guard, a seeded group protected through the config list - known gaps pinned: boolean values in a group's permissions, organisation_role after an unlink
430 lines
19 KiB
Go
430 lines
19 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// TestAdminUsersGroupFilterValue (D-23): the groups filter takes a group id.
|
|
// A value that is not one matches nobody (or is refused with 422 by the
|
|
// framework); it must not reach the database as it is, where a non-numeric
|
|
// text fails the whole list with a 500.
|
|
func TestAdminUsersGroupFilterValue(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
token := env.admin(t, permAccessUsers)
|
|
marker := "gfilter" + adminStamp()
|
|
group := env.seedGroup(t, "Filter "+marker, "")
|
|
member := env.seedUser(t, marker, nil)
|
|
other := env.seedUser(t, marker, nil)
|
|
env.join(t, member, group)
|
|
|
|
gid := strconv.FormatUint(uint64(group.ID), 10)
|
|
if page := env.users(t, token, "search="+marker+"&filter[groups]="+gid); page.Total != 1 || !page.has(member.ID) || page.has(other.ID) {
|
|
t.Fatalf("group filter = %s", page.Raw)
|
|
}
|
|
for _, value := range []string{"abc", "1%20OR%201=1", "-1", "0", "1.5", "99999999999999999999", gid + "x"} {
|
|
rec := env.call(t, http.MethodGet, adminAPI(adminUsersPath)+"?search="+marker+"&filter[groups]="+value, token, nil)
|
|
// The framework refuses a value it cannot read as a scope argument
|
|
// with 422; whatever it lets through must list nobody.
|
|
if rec.Code == http.StatusUnprocessableEntity {
|
|
continue
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("filter[groups]=%s answers %d: %s", value, rec.Code, rec.Body.String())
|
|
}
|
|
var rows []map[string]any
|
|
adminInto(t, rec, &rows)
|
|
if len(rows) != 0 {
|
|
t.Fatalf("filter[groups]=%s lists %d users, want none", value, len(rows))
|
|
}
|
|
}
|
|
// The list without the filter is unaffected.
|
|
if page := env.users(t, token, "search="+marker); page.Total != 2 {
|
|
t.Fatalf("unfiltered list = %s", page.Raw)
|
|
}
|
|
}
|
|
|
|
// adminControllerSet is the plugin's three admin controllers built on one app
|
|
// accessor, by controller id.
|
|
type adminControllerSet map[string]pact.AdminController
|
|
|
|
func adminControllersOn(app func() *backpack.App) adminControllerSet {
|
|
out := adminControllerSet{}
|
|
for _, ctl := range controllers.AdminControllers(app) {
|
|
out[ctl.ID()] = ctl
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bareApp is a booted application value without a database handle, a bucket
|
|
// or a translator.
|
|
func bareApp(t *testing.T) *backpack.App {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-bare\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return backpack.New(cfg)
|
|
}
|
|
|
|
// brokenApp is an application whose database handle fails every query: its
|
|
// connection pool is closed.
|
|
func brokenApp(t *testing.T) (*backpack.App, *gorm.DB) {
|
|
t.Helper()
|
|
adminHarnessDB(t)
|
|
u, err := url.Parse(sessionDSN)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u.Path = "/user_admin"
|
|
db, err := sql.Open("pgx", u.String())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gdb, err := lagoon.Use(t.Context(), db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := bareApp(t)
|
|
if err := lagoon.Publish(app, db, gdb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return app, gdb
|
|
}
|
|
|
|
// wantPlainError asserts that err is an ordinary error: the framework answers
|
|
// it as the opaque 500. A ForbiddenError or a ValidationError would be shown
|
|
// to the administrator.
|
|
func wantPlainError(t *testing.T, what string, err error) {
|
|
t.Helper()
|
|
var forbidden *cabana.ForbiddenError
|
|
var invalid *cabana.ValidationError
|
|
if err == nil || errors.As(err, &forbidden) || errors.As(err, &invalid) {
|
|
t.Fatalf("%s: err = %v, want a plain error", what, err)
|
|
}
|
|
}
|
|
|
|
// TestAdminUsersControllerGuards drives the Users controller directly, the
|
|
// way the framework calls it, on the inputs the HTTP tests cannot produce: a
|
|
// record of another type, an application that is not booted, one without a
|
|
// database handle and one whose database fails. Each ends in a plain error
|
|
// (the opaque 500) or the documented empty answer; none passes a guard.
|
|
func TestAdminUsersControllerGuards(t *testing.T) {
|
|
ctx := context.Background()
|
|
notUser := &models.UserGroup{}
|
|
var nilUser *models.User
|
|
user := &models.User{ID: 987654001, Email: "guards@example.test"}
|
|
|
|
sets := map[string]adminControllerSet{
|
|
"no accessor": adminControllersOn(nil),
|
|
"not booted": adminControllersOn(func() *backpack.App { return nil }),
|
|
"no database": adminControllersOn(func() *backpack.App { return bareApp(t) }),
|
|
"a failing handle": nil,
|
|
"the live database": nil,
|
|
}
|
|
broken, brokenDB := brokenApp(t)
|
|
sets["a failing handle"] = adminControllersOn(func() *backpack.App { return broken })
|
|
env := newAdminEnv(t)
|
|
sets["the live database"] = adminControllersOn(func() *backpack.App { return env.app })
|
|
|
|
for name, set := range sets {
|
|
t.Run(name, func(t *testing.T) {
|
|
ctl := set["golem15.user.users"]
|
|
live := name == "the live database"
|
|
|
|
// Records of another type and nil records.
|
|
for _, record := range []any{notUser, nilUser, nil} {
|
|
wantPlainError(t, "FormBeforeUpdate", ctl.(pact.FormBeforeUpdate).FormBeforeUpdate(ctx, record))
|
|
wantPlainError(t, "FormBeforeCreate", ctl.(pact.FormBeforeCreate).FormBeforeCreate(ctx, record))
|
|
wantPlainError(t, "FormAfterCreate", ctl.(pact.FormAfterCreate).FormAfterCreate(ctx, record))
|
|
wantPlainError(t, "FormBeforeDelete", ctl.(pact.FormBeforeDelete).FormBeforeDelete(ctx, record))
|
|
wantPlainError(t, "FormAfterDelete", ctl.(pact.FormAfterDelete).FormAfterDelete(ctx, record))
|
|
_, err := ctl.(pact.ListRowStates).ListRowStates(ctx, env.gdb, []any{record})
|
|
wantPlainError(t, "ListRowStates", err)
|
|
editor := ctl.(cabana.PermissionEditorProvider)
|
|
_, err = editor.AdminPermissionValues(ctx, "permissions", record)
|
|
wantPlainError(t, "AdminPermissionValues", err)
|
|
wantPlainError(t, "AdminSetPermissionValues", editor.AdminSetPermissionValues(ctx, "permissions", record, map[string]int{"a": 1}))
|
|
for _, action := range ctl.(pact.HasAdminBulkActions).AdminBulkActions() {
|
|
_, err := action.Run(ctx, pact.AdminBulkActionInput{Records: []any{record}})
|
|
wantPlainError(t, "bulk "+action.Name, err)
|
|
}
|
|
for _, action := range ctl.(pact.HasAdminRecordActions).AdminRecordActions() {
|
|
_, err := action.Applies(ctx, record)
|
|
wantPlainError(t, "applies "+action.Name, err)
|
|
_, err = action.Run(ctx, pact.AdminRecordActionInput{Record: record})
|
|
wantPlainError(t, "record "+action.Name, err)
|
|
}
|
|
}
|
|
// The hint of a record that is not a user is empty, never an error.
|
|
if data, err := ctl.(pact.AdminPartialData).PartialData(ctx, "hint", notUser); err != nil || !reflect.ValueOf(data).IsZero() {
|
|
t.Fatalf("hint of another record = %+v %v", data, err)
|
|
}
|
|
if _, err := ctl.(pact.AdminPartialData).PartialData(ctx, "other", user); err == nil || !strings.Contains(err.Error(), `unknown partial "other"`) {
|
|
t.Fatalf("unknown partial: %v", err)
|
|
}
|
|
|
|
// Wrong field names.
|
|
editor := ctl.(cabana.PermissionEditorProvider)
|
|
if _, err := editor.AdminPermissionOptions(ctx, "groups"); err == nil {
|
|
t.Fatal("permission options for another field")
|
|
}
|
|
if _, err := editor.AdminPermissionValues(ctx, "groups", user); err == nil {
|
|
t.Fatal("permission values for another field")
|
|
}
|
|
if err := editor.AdminSetPermissionValues(ctx, "groups", user, nil); err == nil {
|
|
t.Fatal("permission write for another field")
|
|
}
|
|
if lock, err := ctl.(cabana.RelationLockProvider).AdminRelationLocks(ctx, "organisation"); err != nil || len(lock.IDs) != 0 || lock.Message != "" {
|
|
t.Fatalf("locks of another field = %+v %v", lock, err)
|
|
}
|
|
if options := ctl.(pact.FilterOptions).FilterOptions("other"); options != nil {
|
|
t.Fatalf("options of another scope = %v", options)
|
|
}
|
|
if ctl.(pact.ListExtendQuery).ListExtendQuery(ctx, nil) != nil || ctl.(pact.FormExtendQuery).FormExtendQuery(ctx, nil) != nil {
|
|
t.Fatal("an extend query invented a handle")
|
|
}
|
|
|
|
// A bulk or record action outside a transaction never writes.
|
|
for _, action := range ctl.(pact.HasAdminBulkActions).AdminBulkActions() {
|
|
_, err := action.Run(ctx, pact.AdminBulkActionInput{Records: []any{user}})
|
|
wantPlainError(t, "bulk "+action.Name+" without a transaction", err)
|
|
}
|
|
for _, action := range ctl.(pact.HasAdminRecordActions).AdminRecordActions() {
|
|
_, err := action.Run(ctx, pact.AdminRecordActionInput{Record: user})
|
|
wantPlainError(t, "record "+action.Name+" without a transaction", err)
|
|
}
|
|
wantPlainError(t, "FormAfterDelete without a transaction", ctl.(pact.FormAfterDelete).FormAfterDelete(ctx, user))
|
|
|
|
if live {
|
|
// On a working database the same calls answer, and an update
|
|
// of a user who does not exist is an error, not a pass.
|
|
wantPlainError(t, "update of an unknown user", ctl.(pact.FormBeforeUpdate).FormBeforeUpdate(ctx, user))
|
|
if options := ctl.(pact.FilterOptions).FilterOptions(models.FilterByGroup); options == nil {
|
|
t.Fatal("no group choices on a working database")
|
|
}
|
|
if lock, err := ctl.(cabana.RelationLockProvider).AdminRelationLocks(ctx, "groups"); err != nil || lock.Message == "" {
|
|
t.Fatalf("locks without a principal = %+v %v, want the locks", lock, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Without a working database every read that a guard depends on
|
|
// is an error: the guard never answers "not privileged".
|
|
wantPlainError(t, "FormBeforeUpdate", ctl.(pact.FormBeforeUpdate).FormBeforeUpdate(ctx, user))
|
|
wantPlainError(t, "FormBeforeCreate", ctl.(pact.FormBeforeCreate).FormBeforeCreate(ctx, user))
|
|
wantPlainError(t, "FormBeforeDelete", ctl.(pact.FormBeforeDelete).FormBeforeDelete(ctx, user))
|
|
_, err := ctl.(cabana.RelationLockProvider).AdminRelationLocks(ctx, "groups")
|
|
wantPlainError(t, "AdminRelationLocks", err)
|
|
_, err = editor.AdminPermissionOptions(ctx, "permissions")
|
|
wantPlainError(t, "AdminPermissionOptions", err)
|
|
_, err = ctl.(pact.AdminPartialData).PartialData(ctx, "hint", user)
|
|
wantPlainError(t, "PartialData", err)
|
|
for _, action := range ctl.(pact.HasAdminRecordActions).AdminRecordActions() {
|
|
if action.Name == "activate" {
|
|
continue // reads the record only
|
|
}
|
|
_, err := action.Applies(ctx, user)
|
|
wantPlainError(t, "applies "+action.Name, err)
|
|
}
|
|
if options := ctl.(pact.FilterOptions).FilterOptions(models.FilterByGroup); options != nil {
|
|
t.Fatalf("group choices without a database = %v", options)
|
|
}
|
|
if name == "a failing handle" {
|
|
_, err := ctl.(pact.ListRowStates).ListRowStates(ctx, brokenDB, []any{user})
|
|
wantPlainError(t, "ListRowStates on a failing handle", err)
|
|
}
|
|
|
|
// A principal who holds the extra permission, and a superuser,
|
|
// pass the delete guard before any query is made (D-30).
|
|
for _, principal := range []*bouncer.Principal{
|
|
{PermissionGrants: map[string]bool{permManagePrivileged: true}},
|
|
{IsSuperuser: true},
|
|
} {
|
|
if err := ctl.(pact.FormBeforeDelete).FormBeforeDelete(bouncer.WithUser(ctx, principal), user); err != nil {
|
|
t.Fatalf("delete guard for %+v: %v", principal, err)
|
|
}
|
|
if lock, err := ctl.(cabana.RelationLockProvider).AdminRelationLocks(bouncer.WithUser(ctx, principal), "groups"); err != nil || len(lock.IDs) != 0 {
|
|
t.Fatalf("locks for %+v = %+v %v", principal, lock, err)
|
|
}
|
|
}
|
|
// One who holds only the screen's permission does not.
|
|
limited := bouncer.WithUser(ctx, &bouncer.Principal{PermissionGrants: map[string]bool{permAccessUsers: true}})
|
|
wantPlainError(t, "delete guard without the extra permission", ctl.(pact.FormBeforeDelete).FormBeforeDelete(limited, user))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAdminUsersEdge covers the remaining branches of the Users screen
|
|
// through the admin API: an unknown partial, an undeclared filter, options
|
|
// of a field that has none, search and sort, the superuser, and a membership
|
|
// query that fails while the takeover guard needs it (D-30).
|
|
func TestAdminUsersEdge(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
token := env.admin(t, permAccessUsers)
|
|
marker := "edge" + adminStamp()
|
|
surname := "Sur" + marker
|
|
first := env.seedUser(t, "a-"+marker, func(u *models.User) { u.Surname = &surname })
|
|
second := env.seedUser(t, "b-"+marker, nil)
|
|
third := env.seedUser(t, "c-"+marker, nil)
|
|
id := strconv.FormatUint(uint64(first.ID), 10)
|
|
|
|
// Partials: only the declared hint is served.
|
|
for _, name := range []string{"nope", "status", "_hint", "hint.htm", "..%2Fhint"} {
|
|
rec := env.call(t, http.MethodGet, adminAPI(adminUsersPath+"/partials/"+name+"?id="+id), token, nil)
|
|
if rec.Code != http.StatusNotFound && rec.Code != http.StatusBadRequest && rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("partial %s answers %d: %s", name, rec.Code, rec.Body.String())
|
|
}
|
|
assertNoSecret(t, rec.Body.String())
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/partials/hint?id="+id), token, nil)
|
|
env.expect(t, http.StatusNotFound, http.MethodGet, adminAPI(adminUsersPath+"/partials/hint?id=987654321"), token, nil)
|
|
|
|
// Filters: an undeclared name and a malformed value are 422, never a
|
|
// query.
|
|
for _, query := range []string{
|
|
"filter[nope]=1", "filter[password]=x", "filter[activated]=maybe",
|
|
"filter[created_date]=yesterday", "filter[created_date]=2020-06-30..2020-06-01x",
|
|
} {
|
|
env.expect(t, http.StatusUnprocessableEntity, http.MethodGet, adminAPI(adminUsersPath)+"?"+query, token, nil)
|
|
}
|
|
// Options exist for the groups filter and the two relation fields only.
|
|
for _, path := range []string{
|
|
"/filters/activated/options", "/filters/created_date/options", "/filters/nope/options",
|
|
"/fields/name/options", "/fields/password/options", "/fields/permissions/options", "/fields/nope/options",
|
|
} {
|
|
rec := env.call(t, http.MethodGet, adminAPI(adminUsersPath+path), token, nil)
|
|
if rec.Code != http.StatusNotFound && rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("%s answers %d: %s", path, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// Search reaches the invisible surname column; sort orders both ways and
|
|
// refuses a column that is not sortable.
|
|
if page := env.users(t, token, "search="+surname); page.Total != 1 || !page.has(first.ID) {
|
|
t.Fatalf("surname search = %s", page.Raw)
|
|
}
|
|
order := func(query string) []uint {
|
|
ids := []uint{}
|
|
for _, row := range env.users(t, token, "search="+marker+"&"+query).Rows {
|
|
ids = append(ids, uint(row["id"].(float64)))
|
|
}
|
|
return ids
|
|
}
|
|
if got, want := order("sort=email&dir=asc"), []uint{first.ID, second.ID, third.ID}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("email asc = %v, want %v", got, want)
|
|
}
|
|
if got, want := order("sort=email&dir=desc"), []uint{third.ID, second.ID, first.ID}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("email desc = %v, want %v", got, want)
|
|
}
|
|
if got, want := order("sort=id&dir=desc"), []uint{third.ID, second.ID, first.ID}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("id desc = %v, want %v", got, want)
|
|
}
|
|
for _, query := range []string{"sort=password", "sort=permissions", "sort=email&dir=sideways", "sort=email%3Bdrop", "per_page=2"} {
|
|
env.expect(t, http.StatusUnprocessableEntity, http.MethodGet, adminAPI(adminUsersPath)+"?"+query, token, nil)
|
|
}
|
|
|
|
// D-30: a superuser needs no extra permission for a privileged member.
|
|
code := "edgepriv-" + marker
|
|
privileged := func(cfg *compass.Config) {
|
|
if err := cfg.Set("golem15.user.privileged_groups", []string{code}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
env = newAdminEnv(t, privileged)
|
|
limited := env.admin(t, permAccessUsers)
|
|
super := env.admin(t)
|
|
if err := env.gdb.Exec(`UPDATE backend_users SET is_superuser = TRUE WHERE id = (SELECT MAX(id) FROM backend_users)`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
group := env.seedGroup(t, "Edge privileged "+marker, code)
|
|
member := env.seedUser(t, "member-"+marker, nil)
|
|
env.join(t, member, group)
|
|
env.expect(t, http.StatusForbidden, http.MethodPut, userPath(member.ID, ""), limited, map[string]any{"email": "taken-" + marker + "@example.test"})
|
|
renamed := "super-" + marker + "@example.test"
|
|
env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), super, map[string]any{"email": renamed})
|
|
if got := env.reload(t, member.ID); got.Email != renamed {
|
|
t.Fatalf("superuser update: email = %s", got.Email)
|
|
}
|
|
|
|
// D-30: when the membership cannot be read, the guard does not let the
|
|
// request through. The answer is the opaque 500 and nothing changes.
|
|
before := env.reload(t, member.ID)
|
|
victim := env.seedUser(t, "victim-"+marker, nil)
|
|
rename := func(from, to string) {
|
|
t.Helper()
|
|
if err := env.gdb.Exec(`ALTER TABLE ` + from + ` RENAME TO ` + to).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
rename("users_groups", "users_groups_away")
|
|
restored := false
|
|
restore := func() {
|
|
if !restored {
|
|
restored = true
|
|
rename("users_groups_away", "users_groups")
|
|
}
|
|
}
|
|
t.Cleanup(restore)
|
|
for what, rec := range map[string]interface {
|
|
Result() *http.Response
|
|
}{
|
|
"email change": env.call(t, http.MethodPut, userPath(member.ID, ""), limited, map[string]any{"email": "stolen-" + marker + "@example.test"}),
|
|
"password reset": env.call(t, http.MethodPut, userPath(member.ID, ""), limited, map[string]any{"password": "stolen-secret-1", "password_confirmation": "stolen-secret-1"}),
|
|
"delete": env.call(t, http.MethodDelete, userPath(member.ID, ""), limited, nil),
|
|
"bulk delete": env.bulk(t, limited, "delete", victim, member),
|
|
"ordinary delete": env.call(t, http.MethodDelete, userPath(victim.ID, ""), limited, nil),
|
|
} {
|
|
res := rec.Result()
|
|
body := make([]byte, 4096)
|
|
n, _ := res.Body.Read(body)
|
|
text := string(body[:n])
|
|
if res.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("%s with an unreadable membership answers %d: %s", what, res.StatusCode, text)
|
|
}
|
|
if strings.Contains(text, "users_groups") || strings.Contains(strings.ToLower(text), "sqlstate") || strings.Contains(text, "relation") {
|
|
t.Fatalf("%s leaks the database error: %s", what, text)
|
|
}
|
|
}
|
|
restore()
|
|
after := env.reload(t, member.ID)
|
|
if after == nil || after.Email != before.Email || after.Password != before.Password || !reflect.DeepEqual(after.TokensValidAfter, before.TokensValidAfter) {
|
|
t.Fatalf("a failed guard changed the member: %+v", after)
|
|
}
|
|
if env.reload(t, victim.ID) == nil {
|
|
t.Fatal("a failed guard deleted a user")
|
|
}
|
|
if got := env.groupIDs(t, member.ID); !reflect.DeepEqual(got, []uint{group.ID}) {
|
|
t.Fatalf("memberships after the failed guard = %v", got)
|
|
}
|
|
if status, _ := env.userLogin(t, before.Email, "correct-horse"); status != http.StatusOK {
|
|
t.Fatalf("login with the old password = %d", status)
|
|
}
|
|
}
|