mirror of
https://github.com/Eggbertx/gochan.git
synced 2025-09-01 07:26:23 -07:00
Separate staff account password changing from rank changing, add staff callback function tests
This commit is contained in:
parent
bd1039057b
commit
6fcb5fb262
14 changed files with 730 additions and 447 deletions
|
@ -30,4 +30,4 @@
|
|||
color: $a-visited;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -122,7 +122,7 @@ div#upload-box {
|
|||
.postblock {
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
width: 100px;
|
||||
// width: 100px;
|
||||
}
|
||||
|
||||
.post-text, .banned-message {
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
}
|
||||
|
||||
.postblock,
|
||||
table.mgmt-table tr:first-of-type th {
|
||||
table.mgmt-table th {
|
||||
background: colors.$postblock;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
|
|
@ -139,7 +139,6 @@ div#upload-box .upload-x, div#upload-box .upload-filename {
|
|||
.postblock {
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.post-text, .banned-message {
|
||||
|
|
|
@ -53,7 +53,7 @@ div#recent-posts-header {
|
|||
}
|
||||
|
||||
.postblock,
|
||||
table.mgmt-table tr:first-of-type th {
|
||||
table.mgmt-table th {
|
||||
background: #25272D;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
|
|
@ -107,8 +107,16 @@ func (s *Staff) UpdateRank(rank int) error {
|
|||
if rank < 0 || rank > 3 {
|
||||
return ErrInvalidStaffRank
|
||||
}
|
||||
_, err := ExecTimeoutSQL(nil, "UPDATE DBPREFIXstaff SET global_rank = ? WHERE id = ?", rank, s.ID)
|
||||
if err != nil {
|
||||
var err error
|
||||
if s.ID == 0 {
|
||||
// ID field not set yet, get it from the DB
|
||||
s.ID, err = GetStaffID(s.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err = ExecTimeoutSQL(nil, "UPDATE DBPREFIXstaff SET global_rank = ? WHERE id = ?", rank, s.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Rank = rank
|
||||
|
@ -120,8 +128,17 @@ func (s *Staff) UpdatePassword(password string) error {
|
|||
if password == "" {
|
||||
return ErrInvalidStaffPassword
|
||||
}
|
||||
var err error
|
||||
if s.ID == 0 {
|
||||
// ID field not set yet, get it from the DB
|
||||
s.ID, err = GetStaffID(s.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
checksum := gcutil.BcryptSum(password)
|
||||
_, err := ExecTimeoutSQL(nil, "UPDATE DBPREFIXstaff SET password_checksum = ? WHERE id = ?", checksum, s.ID)
|
||||
_, err = ExecTimeoutSQL(nil, "UPDATE DBPREFIXstaff SET password_checksum = ? WHERE id = ?", checksum, s.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -129,38 +146,10 @@ func (s *Staff) UpdatePassword(password string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// UpdateStaff sets the rank and/or password of the staff account with the given username. If password
|
||||
// is blank, only the rank will be updated
|
||||
func UpdateStaff(username string, rank int, password string) error {
|
||||
// first check if it's a recognized username
|
||||
id, err := GetStaffID(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlUpdate := "UPDATE DBPREFIXstaff SET global_rank = ?"
|
||||
args := []any{rank}
|
||||
if password != "" {
|
||||
sqlUpdate += ", password_checksum = ?"
|
||||
args = append(args, gcutil.BcryptSum(password))
|
||||
}
|
||||
sqlUpdate += " WHERE id = ?"
|
||||
args = append(args, id)
|
||||
|
||||
_, err = ExecTimeoutSQL(nil, sqlUpdate, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateStaff sets the password of the staff account with the given username
|
||||
func UpdatePassword(username string, newPassword string) error {
|
||||
const sqlUPDATE = `UPDATE DBPREFIXstaff SET password_checksum = ? WHERE id = ?`
|
||||
id, err := GetStaffID(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
checksum := gcutil.BcryptSum(newPassword)
|
||||
|
||||
_, err = ExecTimeoutSQL(nil, sqlUPDATE, checksum, id)
|
||||
return err
|
||||
// UpdateStaffPassword sets the password of the staff account with the given username
|
||||
func UpdateStaffPassword(username string, newPassword string) error {
|
||||
staff := Staff{Username: username}
|
||||
return staff.UpdatePassword(newPassword)
|
||||
}
|
||||
|
||||
// EndStaffSession deletes any session rows associated with the requests session cookie and then
|
||||
|
|
|
@ -118,46 +118,47 @@ func (u *Upload) IsEmbed() bool {
|
|||
return strings.HasPrefix(u.Filename, "embed:")
|
||||
}
|
||||
|
||||
// IPBanBase used to composition IPBan and IPBanAudit. It does not represent a SQL table by itself
|
||||
// IPBanBase is used by IPBan and IPBanAudit. It does not represent a SQL table by itself
|
||||
type IPBanBase struct {
|
||||
IsActive bool
|
||||
IsThreadBan bool
|
||||
ExpiresAt time.Time
|
||||
StaffID int
|
||||
AppealAt time.Time
|
||||
Permanent bool
|
||||
StaffNote string
|
||||
Message string
|
||||
CanAppeal bool
|
||||
IsActive bool // sql: is_active
|
||||
IsThreadBan bool // sql: is_thread_ban
|
||||
ExpiresAt time.Time // sql: expires_at
|
||||
StaffID int // sql: staff_id
|
||||
AppealAt time.Time // sql: appeal_at
|
||||
Permanent bool // sql: permanent
|
||||
StaffNote string // sql: staff_note
|
||||
Message string // sql: message
|
||||
CanAppeal bool // sql: can_appeal
|
||||
}
|
||||
|
||||
// IPBan contains the information association with a specific ip ban.
|
||||
// table: DBPREFIXip_ban
|
||||
type IPBan struct {
|
||||
ID int
|
||||
BoardID *int
|
||||
BannedForPostID *int
|
||||
CopyPostText template.HTML
|
||||
RangeStart string
|
||||
RangeEnd string
|
||||
IssuedAt time.Time
|
||||
ID int // sql: id
|
||||
BoardID *int // sql: board_id
|
||||
BannedForPostID *int // sql: banned_for_post_id
|
||||
CopyPostText template.HTML // sql: copy_post_text
|
||||
RangeStart string // sql: range_start
|
||||
RangeEnd string // sql: range_end
|
||||
IssuedAt time.Time // sql: issued_at
|
||||
IPBanBase
|
||||
}
|
||||
|
||||
// Deprecated: Use the RangeStart and RangeEnd fields or gcutil.GetIPRangeSubnet.
|
||||
// IP was previously a field in the IPBan struct before range bans were
|
||||
// implemented. This is here as a fallback for templates
|
||||
func (ipb *IPBan) IP() string {
|
||||
func (ipb *IPBan) IP() (string, error) {
|
||||
if ipb.RangeStart == ipb.RangeEnd {
|
||||
return ipb.RangeStart
|
||||
return ipb.RangeStart, nil
|
||||
}
|
||||
inet, err := gcutil.GetIPRangeSubnet(ipb.RangeStart, ipb.RangeEnd)
|
||||
if err != nil {
|
||||
return "?"
|
||||
return "", err
|
||||
}
|
||||
return inet.String()
|
||||
return inet.String(), nil
|
||||
}
|
||||
|
||||
// IsBanned returns true if the given IP is banned
|
||||
func (ipb *IPBan) IsBanned(ipStr string) (bool, error) {
|
||||
ipn, err := gcutil.GetIPRangeSubnet(ipb.RangeStart, ipb.RangeEnd)
|
||||
if err != nil {
|
||||
|
|
|
@ -16,12 +16,13 @@ import (
|
|||
"github.com/gochan-org/gochan/pkg/gctemplates"
|
||||
"github.com/gochan-org/gochan/pkg/gcutil"
|
||||
"github.com/gochan-org/gochan/pkg/posting"
|
||||
"github.com/gochan-org/gochan/pkg/server"
|
||||
"github.com/gochan-org/gochan/pkg/server/serverutil"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInsufficientPermission = errors.New("insufficient account permission")
|
||||
ErrInsufficientPermission = server.NewServerError("insufficient account permission", http.StatusForbidden)
|
||||
)
|
||||
|
||||
type uploadInfo struct {
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Eggbertx/go-forms"
|
||||
"github.com/gochan-org/gochan/pkg/building"
|
||||
"github.com/gochan-org/gochan/pkg/config"
|
||||
"github.com/gochan-org/gochan/pkg/gcsql"
|
||||
|
@ -127,10 +128,10 @@ type formMode int
|
|||
|
||||
func (fmv formMode) String() string {
|
||||
switch fmv {
|
||||
case updateOwnPasswordForm:
|
||||
return "Update Password"
|
||||
case updateUserForm:
|
||||
return "Update User"
|
||||
case changePasswordForm:
|
||||
return "Change Password"
|
||||
case changeRankForm:
|
||||
return "Change User Rank"
|
||||
case newUserForm:
|
||||
return "Add New User"
|
||||
}
|
||||
|
@ -139,19 +140,79 @@ func (fmv formMode) String() string {
|
|||
|
||||
const (
|
||||
noForm formMode = iota
|
||||
updateOwnPasswordForm
|
||||
updateUserForm
|
||||
changePasswordForm
|
||||
changeRankForm
|
||||
newUserForm
|
||||
)
|
||||
|
||||
type staffForm struct {
|
||||
Do string `form:"do"`
|
||||
ChangePasswordForUser string `form:"changepass" method:"GET"`
|
||||
ChangeRankForUser string `form:"changerank" method:"GET"`
|
||||
Username string `form:"username"`
|
||||
Password string `form:"password" method:"POST"`
|
||||
PasswordConfirm string `form:"passwordconfirm" method:"POST"`
|
||||
Rank int `form:"rank" method:"POST"`
|
||||
}
|
||||
|
||||
func (s *staffForm) validate(staff *gcsql.Staff, warnEv *zerolog.Event) (formMode, error) {
|
||||
if s.Do == "add" || (s.Do == "changepass" && s.Username != staff.Username) || s.Do == "changerank" || s.Do == "del" {
|
||||
if staff.Rank < 3 {
|
||||
warnEv.Caller().
|
||||
Str("username", s.Username).
|
||||
Str("do", s.Do).
|
||||
Msg("non-admin tried to modify someone else's account or create a new account")
|
||||
return noForm, ErrInsufficientPermission
|
||||
}
|
||||
}
|
||||
|
||||
if (s.Do == "del" || s.Do == "add") && s.Username == "" {
|
||||
warnEv.Caller().Str("do", s.Do).Msg("Missing username field")
|
||||
return noForm, errors.New("missing username field")
|
||||
}
|
||||
|
||||
if s.Do == "add" && s.Password == "" {
|
||||
warnEv.Caller().Str("do", s.Do).Msg("Missing password field")
|
||||
return noForm, errors.New("missing password field")
|
||||
}
|
||||
if s.Do == "add" && s.Password != s.PasswordConfirm {
|
||||
warnEv.Caller().Str("do", s.Do).Err(ErrPasswordsDoNotMatch).Send()
|
||||
return noForm, ErrPasswordsDoNotMatch
|
||||
}
|
||||
|
||||
if s.Do != "" && s.Do != "add" && s.Do != "changepass" && s.Do != "changerank" && s.Do != "del" {
|
||||
warnEv.Caller().Str("do", s.Do).Msg("Invalid form action")
|
||||
return noForm, errors.New("invalid form action")
|
||||
}
|
||||
|
||||
if s.ChangePasswordForUser != "" {
|
||||
if s.ChangePasswordForUser != staff.Username && staff.Rank < 3 {
|
||||
warnEv.Caller().Str("username", s.Username).Msg("non-admin tried to change a password")
|
||||
return noForm, ErrInsufficientPermission
|
||||
}
|
||||
return changePasswordForm, nil
|
||||
}
|
||||
if s.ChangeRankForUser != "" {
|
||||
if staff.Rank < 3 {
|
||||
warnEv.Caller().Str("username", s.Username).Msg("non-admin tried to change a rank")
|
||||
return noForm, ErrInsufficientPermission
|
||||
}
|
||||
return changeRankForm, nil
|
||||
}
|
||||
if staff.Rank >= 3 {
|
||||
return newUserForm, nil
|
||||
}
|
||||
return noForm, nil
|
||||
}
|
||||
|
||||
func staffCallback(writer http.ResponseWriter, request *http.Request, staff *gcsql.Staff, wantsJSON bool, infoEv *zerolog.Event, errEv *zerolog.Event) (output any, err error) {
|
||||
var allStaff []gcsql.Staff
|
||||
allStaff, err = getAllStaffNopass(true)
|
||||
if err != nil {
|
||||
errEv.Err(err).Caller().Msg("Failed getting staff list")
|
||||
return nil, errors.New("unable to get staff list")
|
||||
}
|
||||
if wantsJSON {
|
||||
allStaff, err = getAllStaffNopass(true)
|
||||
if err != nil {
|
||||
errEv.Err(err).Caller().Msg("Failed getting staff list")
|
||||
return nil, errors.New("unable to get staff list")
|
||||
}
|
||||
return allStaff, nil
|
||||
}
|
||||
|
||||
|
@ -161,151 +222,105 @@ func staffCallback(writer http.ResponseWriter, request *http.Request, staff *gcs
|
|||
Str("staff", staff.Username)
|
||||
defer warnEv.Discard()
|
||||
|
||||
do := request.PostFormValue("do")
|
||||
updateUsername := request.FormValue("update")
|
||||
username := request.PostFormValue("username")
|
||||
password := request.PostFormValue("password")
|
||||
passwordConfirm := request.FormValue("passwordconfirm")
|
||||
if (do == "add" || do == "update") && password != passwordConfirm {
|
||||
return "", ErrPasswordsDoNotMatch
|
||||
var form staffForm
|
||||
var numErr *strconv.NumError
|
||||
err = forms.FillStructFromForm(request, &form)
|
||||
if errors.As(err, &numErr) {
|
||||
errEv.Err(err).Caller().
|
||||
Str("value", numErr.Num).
|
||||
Msg("Error parsing form value")
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
return "", err
|
||||
} else if err != nil {
|
||||
errEv.Err(err).Caller().
|
||||
Str("form", "staffForm").
|
||||
Msg("Error filling form struct")
|
||||
return "", err
|
||||
}
|
||||
if username != "" {
|
||||
gcutil.LogStr("username", username, infoEv, errEv, warnEv)
|
||||
formMode, err := form.validate(staff, warnEv)
|
||||
if errors.Is(err, ErrInsufficientPermission) {
|
||||
writer.WriteHeader(http.StatusForbidden)
|
||||
return "", err
|
||||
}
|
||||
if err != nil {
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
return "", err
|
||||
}
|
||||
|
||||
rankStr := request.PostFormValue("rank")
|
||||
var rank int
|
||||
if rankStr != "" {
|
||||
if staff.Rank < 3 {
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
warnEv.Caller().Str("username", username).Msg("non-admin tried to modify a staff account's rank")
|
||||
return "", ErrInsufficientPermission
|
||||
}
|
||||
if rank, err = strconv.Atoi(rankStr); err != nil {
|
||||
if form.Username != "" {
|
||||
gcutil.LogStr("username", form.Username, infoEv, errEv, warnEv)
|
||||
}
|
||||
|
||||
updateStaff := &gcsql.Staff{
|
||||
Username: form.Username,
|
||||
Rank: form.Rank,
|
||||
}
|
||||
switch formMode {
|
||||
case changePasswordForm:
|
||||
updateStaff, err = gcsql.GetStaffByUsername(form.ChangePasswordForUser, true)
|
||||
if err != nil {
|
||||
errEv.Err(err).Caller().
|
||||
Str("rank", rankStr).Send()
|
||||
Str("username", form.ChangePasswordForUser).
|
||||
Msg("Error getting staff account")
|
||||
return "", err
|
||||
}
|
||||
if rank < 0 || rank > 3 {
|
||||
errEv.Caller().Int("rank", rank).Send()
|
||||
return "", errors.New("invalid rank")
|
||||
case changeRankForm:
|
||||
updateStaff, err = gcsql.GetStaffByUsername(form.ChangeRankForUser, true)
|
||||
if err != nil {
|
||||
errEv.Err(err).Caller().
|
||||
Str("username", form.ChangeRankForUser).
|
||||
Msg("Error getting staff account")
|
||||
return "", err
|
||||
}
|
||||
case newUserForm:
|
||||
updateStaff.Username = form.Username
|
||||
}
|
||||
|
||||
var formMode = noForm
|
||||
if staff.Rank == 3 {
|
||||
if updateUsername == "" {
|
||||
formMode = newUserForm
|
||||
} else {
|
||||
formMode = updateUserForm
|
||||
switch form.Do {
|
||||
case "add":
|
||||
if updateStaff, err = gcsql.NewStaff(form.Username, form.Password, form.Rank); err != nil {
|
||||
errEv.Err(err).Caller().
|
||||
Str("username", form.Username).
|
||||
Msg("Error creating new staff account")
|
||||
return "", fmt.Errorf("unable to create new staff account: %w", err)
|
||||
}
|
||||
} else {
|
||||
if updateUsername == staff.Username {
|
||||
formMode = updateOwnPasswordForm
|
||||
} else if updateUsername != "" {
|
||||
// user is a moderator or janitor and is trying to update someone else's account
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
return nil, ErrInsufficientPermission
|
||||
infoEv.Str("userRank", updateStaff.RankTitle()).Msg("New staff account created")
|
||||
case "changepass":
|
||||
if err = updateStaff.UpdatePassword(form.Password); err != nil {
|
||||
errEv.Err(err).Caller().Msg("Error updating password")
|
||||
return "", errors.New("unable to change staff account password")
|
||||
}
|
||||
infoEv.Msg("Password updated")
|
||||
case "changerank":
|
||||
if err = updateStaff.UpdateRank(form.Rank); err != nil {
|
||||
errEv.Err(err).Caller().Msg("Error updating rank")
|
||||
return "", errors.New("unable to change staff account rank")
|
||||
}
|
||||
infoEv.
|
||||
Int("rank", updateStaff.Rank).
|
||||
Str("rankTitle", updateStaff.RankTitle()).
|
||||
Msg("Staff account rank updated")
|
||||
case "del":
|
||||
if err = updateStaff.ClearSessions(); err != nil {
|
||||
errEv.Err(err).Caller().
|
||||
Str("username", form.Username).
|
||||
Msg("Unable to clear user login sessions")
|
||||
return "", errors.New("unable to clear user login sessions")
|
||||
}
|
||||
if err = updateStaff.SetActive(false); err != nil {
|
||||
errEv.Err(err).Caller().
|
||||
Str("username", form.Username).
|
||||
Msg("Unable to deactivate user")
|
||||
return "", errors.New("unable to deactivate user")
|
||||
}
|
||||
infoEv.Str("userRank", updateStaff.RankTitle()).Msg("Account deactivated")
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"updateUsername": updateUsername,
|
||||
"currentStaff": staff,
|
||||
"formMode": formMode,
|
||||
"updateRank": -1,
|
||||
}
|
||||
if updateUsername != "" && staff.Rank == AdminPerms {
|
||||
var found bool
|
||||
for _, user := range allStaff {
|
||||
if user.Username == updateUsername {
|
||||
data["updateRank"] = user.Rank
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
gcutil.LogStr("updateUsername", updateUsername, infoEv, errEv, warnEv)
|
||||
if !found {
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
warnEv.Err(gcsql.ErrUnrecognizedUsername).Caller().Send()
|
||||
return "", gcsql.ErrUnrecognizedUsername
|
||||
}
|
||||
}
|
||||
|
||||
if do == "add" {
|
||||
if staff.Rank < 3 {
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
warnEv.Caller().Str("username", username).Msg("non-admin tried to create a new account")
|
||||
return "", ErrInsufficientPermission
|
||||
}
|
||||
var newStaff *gcsql.Staff
|
||||
if newStaff, err = gcsql.NewStaff(username, password, rank); err != nil {
|
||||
errEv.Err(err).Caller().
|
||||
Str("userRank", newStaff.RankTitle()).
|
||||
Msg("Error creating new staff account")
|
||||
return "", fmt.Errorf("unable to create new staff account %q by %q: %s",
|
||||
username, staff.Username, err.Error())
|
||||
}
|
||||
infoEv.Str("userRank", newStaff.RankTitle()).Msg("New staff account created")
|
||||
} else if do == "update" || do == "del" {
|
||||
if updateUsername == "" {
|
||||
warnEv.Caller().Str("do", do).Msg("Missing username field")
|
||||
return nil, errors.New("missing username field")
|
||||
}
|
||||
if (do == "update" && staff.Rank < AdminPerms && updateUsername != staff.Username) || (do == "del" && staff.Rank < AdminPerms) {
|
||||
// user is not an admin and is trying to update someone else's account (rank change already checked)
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
warnEv.Err(ErrInsufficientPermission).Send()
|
||||
return nil, ErrInsufficientPermission
|
||||
}
|
||||
|
||||
var user *gcsql.Staff
|
||||
if user, err = gcsql.GetStaffByUsername(updateUsername, true); err != nil {
|
||||
errEv.Err(err).Caller().Bool("onlyActive", true).Msg("Unable to get staff by username")
|
||||
return nil, err
|
||||
}
|
||||
gcutil.LogStr("userRank", user.RankTitle(), infoEv, errEv, warnEv)
|
||||
|
||||
if do == "update" {
|
||||
if password != "" {
|
||||
if err = user.UpdatePassword(password); err != nil {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
errEv.Err(err).Caller().
|
||||
Msg("Error updating password")
|
||||
return "", errors.New("unable to update staff account password")
|
||||
}
|
||||
infoEv.Msg("Password updated")
|
||||
} else if rank > 0 {
|
||||
if err = user.UpdateRank(rank); err != nil {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
errEv.Err(err).Caller().
|
||||
Msg("Error updating rank")
|
||||
return "", errors.New("unable to update staff account rank")
|
||||
}
|
||||
infoEv.
|
||||
Int("rank", user.Rank).
|
||||
Str("rankTitle", user.RankTitle()).
|
||||
Msg("Staff account rank updated")
|
||||
}
|
||||
data["formMode"] = newUserForm
|
||||
data["updateUsername"] = ""
|
||||
data["updateRank"] = -1
|
||||
} else {
|
||||
// deactivate account
|
||||
if err = user.ClearSessions(); err != nil {
|
||||
errEv.Err(err).Caller().
|
||||
Msg("Unable to clear user login sessions")
|
||||
return nil, errors.New("unable to clear user login sessions")
|
||||
}
|
||||
|
||||
if err = user.SetActive(false); err != nil {
|
||||
errEv.Err(err).Caller().
|
||||
Msg("Unable to deactivate user")
|
||||
return nil, errors.New("unable to deactivate user")
|
||||
}
|
||||
infoEv.Msg("Account deactivated")
|
||||
}
|
||||
|
||||
"username": updateStaff.Username,
|
||||
"rank": updateStaff.Rank,
|
||||
"currentStaff": staff,
|
||||
"formMode": formMode,
|
||||
}
|
||||
|
||||
data["allstaff"], err = getAllStaffNopass(true)
|
||||
|
@ -314,13 +329,13 @@ func staffCallback(writer http.ResponseWriter, request *http.Request, staff *gcs
|
|||
return nil, errors.New("unable to get staff list")
|
||||
}
|
||||
|
||||
staffBuffer := bytes.NewBufferString("")
|
||||
if err = serverutil.MinifyTemplate(gctemplates.ManageStaff, data, staffBuffer, "text/html"); err != nil {
|
||||
buffer := bytes.NewBufferString("")
|
||||
if err = serverutil.MinifyTemplate(gctemplates.ManageStaff, data, buffer, "text/html"); err != nil {
|
||||
errEv.Err(err).Str("template", "manage_staff.html").Send()
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
return "", errors.New("unable to execute staff management page template")
|
||||
}
|
||||
return staffBuffer.String(), nil
|
||||
return buffer.String(), nil
|
||||
}
|
||||
|
||||
func registerJanitorPages() {
|
||||
|
|
|
@ -3,6 +3,7 @@ package manage
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
@ -111,7 +112,12 @@ func setupManageFunction(action *Action) bunrouter.HandlerFunc {
|
|||
}
|
||||
}
|
||||
if err != nil {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
var serverError *server.ServerError
|
||||
if errors.As(err, &serverError) {
|
||||
writer.WriteHeader(serverError.StatusCode)
|
||||
} else {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
serveError(writer, "actionerror", action.ID, err.Error(), wantsJSON || (action.JSONoutput == AlwaysJSON))
|
||||
return
|
||||
}
|
||||
|
|
441
pkg/manage/manage_test.go
Normal file
441
pkg/manage/manage_test.go
Normal file
|
@ -0,0 +1,441 @@
|
|||
package manage
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"maps"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/gochan-org/gochan/pkg/config"
|
||||
"github.com/gochan-org/gochan/pkg/gcsql"
|
||||
"github.com/gochan-org/gochan/pkg/gctemplates"
|
||||
"github.com/gochan-org/gochan/pkg/gcutil"
|
||||
"github.com/gochan-org/gochan/pkg/gcutil/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
loginQueryRE = `SELECT\s*id,\s*username,\s*password_checksum,\s*global_rank,\s*added_on,\s*last_login,\s*is_active\s*FROM staff WHERE username = \? AND is_active = TRUE`
|
||||
insertSessionRE = `INSERT INTO sessions \(staff_id,data,expires\) VALUES\(\?,\?,\?\)`
|
||||
updateStaffLoginRE = `UPDATE staff SET last_login = CURRENT_TIMESTAMP WHERE id = \?`
|
||||
)
|
||||
|
||||
var (
|
||||
genericStaffList = []gcsql.Staff{
|
||||
{Username: "admin", Rank: 3},
|
||||
{Username: "mod", Rank: 2},
|
||||
{Username: "janitor", Rank: 1},
|
||||
}
|
||||
|
||||
loginTestCases = []manageCallbackTestCase{
|
||||
{
|
||||
desc: "GET login",
|
||||
path: "/manage/login",
|
||||
method: "GET",
|
||||
expectStatus: http.StatusOK,
|
||||
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
|
||||
if !assert.NotNil(t, output) {
|
||||
t.FailNow()
|
||||
}
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(output.(string)))
|
||||
if !assert.NoError(t, err) {
|
||||
t.FailNow()
|
||||
}
|
||||
assert.Equal(t, 1, doc.Find("input[name=username]").Length())
|
||||
assert.Equal(t, 1, doc.Find("input[name=password]").Length())
|
||||
assert.Equal(t, 1, doc.Find("input[value=Login]").Length())
|
||||
},
|
||||
}, {
|
||||
desc: "POST login",
|
||||
method: "POST",
|
||||
path: "/manage/login",
|
||||
header: http.Header{
|
||||
"Referer": []string{"http://localhost/manage/login"},
|
||||
},
|
||||
form: url.Values{
|
||||
"username": {"admin"},
|
||||
"password": {"password"},
|
||||
},
|
||||
expectStatus: http.StatusFound,
|
||||
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
expectedSum := gcutil.BcryptSum("password")
|
||||
mock.ExpectPrepare(loginQueryRE).ExpectQuery().WithArgs("admin").WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
|
||||
AddRow(1, "admin", expectedSum, 1, time.Now(), time.Now(), true),
|
||||
)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectPrepare(insertSessionRE).ExpectExec().WithArgs(1, sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectPrepare(updateStaffLoginRE).ExpectExec().WithArgs(1).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
},
|
||||
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
|
||||
assert.Nil(t, output) // redirect, output is nil
|
||||
},
|
||||
}, {
|
||||
desc: "POST login with invalid credentials",
|
||||
method: "POST",
|
||||
path: "/manage/login",
|
||||
header: http.Header{
|
||||
"Referer": []string{"http://localhost/manage/login"},
|
||||
},
|
||||
form: url.Values{
|
||||
"username": {"admin"},
|
||||
"password": {"wrongpassword"},
|
||||
},
|
||||
expectStatus: http.StatusUnauthorized,
|
||||
expectError: true,
|
||||
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
notExpectedSum := gcutil.BcryptSum("password")
|
||||
mock.ExpectPrepare(loginQueryRE).ExpectQuery().WithArgs("admin").WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
|
||||
AddRow(1, "admin", notExpectedSum, 1, time.Now(), time.Now(), true),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
staffTestCases = []manageCallbackTestCase{
|
||||
{
|
||||
desc: "View staff list as admin",
|
||||
method: "GET",
|
||||
path: "/manage/staff",
|
||||
staff: &gcsql.Staff{Rank: 3, Username: "admin"},
|
||||
expectStatus: http.StatusOK,
|
||||
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
getStaffMockHelper(t, mock)
|
||||
},
|
||||
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
|
||||
validateStaffOutput(t, &gcsql.Staff{Username: "admin", Rank: 3}, output, newUserForm)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "View staff list as mod",
|
||||
method: "GET",
|
||||
path: "/manage/staff",
|
||||
staff: &gcsql.Staff{Username: "mod", Rank: 2},
|
||||
expectStatus: http.StatusOK,
|
||||
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
getStaffMockHelper(t, mock)
|
||||
},
|
||||
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
|
||||
validateStaffOutput(t, &gcsql.Staff{Username: "mod", Rank: 2}, output, noForm)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "View change rank form as admin",
|
||||
method: "GET",
|
||||
path: "/manage/staff?changerank=admin",
|
||||
staff: &gcsql.Staff{Username: "admin", Rank: 3},
|
||||
expectStatus: http.StatusOK,
|
||||
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
mock.ExpectPrepare(`SELECT id, username, password_checksum, global_rank, added_on, last_login, is_active FROM staff WHERE username = \? AND is_active = TRUE`).
|
||||
ExpectQuery().WithArgs("admin").WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
|
||||
AddRow(1, "admin", gcutil.BcryptSum("password"), 3, time.Now(), time.Now(), true),
|
||||
)
|
||||
getStaffMockHelper(t, mock)
|
||||
},
|
||||
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
|
||||
validateStaffOutput(t, &gcsql.Staff{Username: "admin", Rank: 3}, output, changeRankForm)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "View change password form as admin",
|
||||
method: "GET",
|
||||
path: "/manage/staff?changepass=admin",
|
||||
staff: &gcsql.Staff{Username: "admin", Rank: 3},
|
||||
expectStatus: http.StatusOK,
|
||||
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
mock.ExpectPrepare(`SELECT id, username, password_checksum, global_rank, added_on, last_login, is_active FROM staff WHERE username = \? AND is_active = TRUE`).
|
||||
ExpectQuery().WithArgs("admin").WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
|
||||
AddRow(1, "admin", gcutil.BcryptSum("password"), 3, time.Now(), time.Now(), true),
|
||||
)
|
||||
getStaffMockHelper(t, mock)
|
||||
},
|
||||
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
|
||||
validateStaffOutput(t, &gcsql.Staff{Username: "admin", Rank: 3}, output, changePasswordForm)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "View change password form as mod for self",
|
||||
method: "GET",
|
||||
path: "/manage/staff?changepass=mod",
|
||||
staff: &gcsql.Staff{Username: "mod", Rank: 2},
|
||||
expectStatus: http.StatusOK,
|
||||
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
mock.ExpectPrepare(`SELECT id, username, password_checksum, global_rank, added_on, last_login, is_active FROM staff WHERE username = \? AND is_active = TRUE`).
|
||||
ExpectQuery().WithArgs("mod").WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
|
||||
AddRow(2, "mod", gcutil.BcryptSum("password"), 2, time.Now(), time.Now(), true),
|
||||
)
|
||||
getStaffMockHelper(t, mock)
|
||||
},
|
||||
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
|
||||
validateStaffOutput(t, &gcsql.Staff{Username: "mod", Rank: 2}, output, changePasswordForm)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "View change password form as mod for another account",
|
||||
method: "GET",
|
||||
path: "/manage/staff?changepass=janitor",
|
||||
staff: &gcsql.Staff{Username: "mod", Rank: 2},
|
||||
expectStatus: http.StatusForbidden,
|
||||
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
mock.ExpectPrepare(`SELECT id, username, password_checksum, global_rank, added_on, last_login, is_active FROM staff WHERE username = \? AND is_active = TRUE`).
|
||||
ExpectQuery().WithArgs("janitor").WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
|
||||
AddRow(3, "janitor", gcutil.BcryptSum("password"), 1, time.Now(), time.Now(), true),
|
||||
)
|
||||
getStaffMockHelper(t, mock)
|
||||
},
|
||||
expectError: true,
|
||||
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, err error) {
|
||||
assert.Equal(t, http.StatusForbidden, writer.Code)
|
||||
assert.ErrorIs(t, err, ErrInsufficientPermission)
|
||||
assert.Empty(t, output)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "View change change rank form as mod",
|
||||
method: "GET",
|
||||
path: "/manage/staff?changerank=mod",
|
||||
staff: &gcsql.Staff{Username: "mod", Rank: 2},
|
||||
expectStatus: http.StatusForbidden,
|
||||
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
mock.ExpectPrepare(`SELECT id, username, password_checksum, global_rank, added_on, last_login, is_active FROM staff WHERE username = \? AND is_active = TRUE`).
|
||||
ExpectQuery().WithArgs("mod").WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
|
||||
AddRow(2, "mod", gcutil.BcryptSum("password"), 2, time.Now(), time.Now(), true),
|
||||
)
|
||||
getStaffMockHelper(t, mock)
|
||||
},
|
||||
expectError: true,
|
||||
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, err error) {
|
||||
assert.Equal(t, http.StatusForbidden, writer.Code)
|
||||
assert.ErrorIs(t, err, ErrInsufficientPermission)
|
||||
assert.Empty(t, output)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "Create new user as admin",
|
||||
method: "POST",
|
||||
path: "/manage/staff",
|
||||
staff: &gcsql.Staff{Username: "admin", Rank: 3},
|
||||
expectStatus: http.StatusOK,
|
||||
form: url.Values{
|
||||
"do": {"add"},
|
||||
"username": {"newuser"},
|
||||
"password": {"newpassword"},
|
||||
"passwordconfirm": {"newpassword"},
|
||||
"rank": {"1"},
|
||||
},
|
||||
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
mock.ExpectPrepare(`INSERT INTO staff \(username, password_checksum, global_rank\) VALUES\(\?,\?,\?\)`).ExpectExec().
|
||||
WithArgs("newuser", sqlmock.AnyArg(), 1).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
getStaffMockHelper(t, mock,
|
||||
gcsql.Staff{Username: "admin", Rank: 3},
|
||||
gcsql.Staff{Username: "mod", Rank: 2},
|
||||
gcsql.Staff{Username: "janitor", Rank: 1},
|
||||
gcsql.Staff{Username: "newuser", Rank: 1})
|
||||
},
|
||||
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
|
||||
expectedStaff := append(genericStaffList, gcsql.Staff{Username: "newuser", Rank: 1})
|
||||
validateStaffOutput(t, &gcsql.Staff{Username: "admin", Rank: 3}, output, newUserForm, expectedStaff...)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func getStaffMockHelper(t *testing.T, mock sqlmock.Sqlmock, expectedStaff ...gcsql.Staff) {
|
||||
t.Helper()
|
||||
|
||||
if len(expectedStaff) == 0 {
|
||||
expectedStaff = genericStaffList
|
||||
}
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "username", "global_rank", "added_on", "last_login", "is_active"})
|
||||
for _, staff := range expectedStaff {
|
||||
rows.AddRow(1, staff.Username, staff.Rank, time.Now(), time.Now(), true)
|
||||
}
|
||||
mock.ExpectPrepare(`SELECT\s*id,\s*username,\s*global_rank,\s*added_on,\s*last_login,\s*is_active\s*FROM staff WHERE is_active`).
|
||||
ExpectQuery().WillReturnRows(rows)
|
||||
}
|
||||
|
||||
func validateStaffOutput(t *testing.T, staff *gcsql.Staff, output any, expectedFormMode formMode, expectedStaffList ...gcsql.Staff) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(output.(string)))
|
||||
if !assert.NoError(t, err) {
|
||||
t.FailNow()
|
||||
}
|
||||
if len(expectedStaffList) == 0 {
|
||||
expectedStaffList = append(expectedStaffList, genericStaffList...)
|
||||
}
|
||||
|
||||
staffRows := doc.Find("table.stafflist tr")
|
||||
assert.Equal(t, len(expectedStaffList)+1, staffRows.Length())
|
||||
staffRows.Each(func(i int, s *goquery.Selection) {
|
||||
if i == 0 {
|
||||
return
|
||||
}
|
||||
if i > len(expectedStaffList) {
|
||||
assert.Fail(t, "More staff rows than expected")
|
||||
return
|
||||
}
|
||||
expectedStaff := expectedStaffList[i-1]
|
||||
assert.Equal(t, expectedStaff.Username, s.Find("td").Eq(0).Text())
|
||||
assert.Equal(t, expectedStaff.RankTitle(), s.Find("td").Eq(1).Text())
|
||||
if staff.Rank == 3 && expectedStaff.Username == staff.Username {
|
||||
assert.Equal(t, "Change Password | Change Rank", s.Find("td").Eq(3).Text())
|
||||
} else if staff.Rank == 3 {
|
||||
assert.Equal(t, "Change Password | Change Rank | Delete", s.Find("td").Eq(3).Text())
|
||||
} else if staff.Rank < 3 && expectedStaff.Username == staff.Username {
|
||||
assert.Equal(t, "Change Password", s.Find("td").Eq(3).Text())
|
||||
} else {
|
||||
assert.Equal(t, "", s.Find("td").Eq(3).Text())
|
||||
}
|
||||
})
|
||||
|
||||
hidden := doc.Find("input[type=hidden]")
|
||||
switch expectedFormMode {
|
||||
case newUserForm:
|
||||
assert.Equal(t, "Add New User", doc.Find("h2").Text())
|
||||
assert.Equal(t, 1, doc.Find("input[name=username]").Length())
|
||||
assert.Equal(t, 1, doc.Find("input[name=password]").Length())
|
||||
assert.Equal(t, 1, doc.Find("input[name=passwordconfirm]").Length())
|
||||
assert.Equal(t, 1, doc.Find("select[name=rank]").Length())
|
||||
assert.Equal(t, 1, doc.Find("input[value='Create User']").Length())
|
||||
assert.Equal(t, 0, doc.Find("input[value=Cancel]").Length())
|
||||
assert.Equal(t, "add", hidden.Filter("[name=do]").AttrOr("value", ""))
|
||||
case changePasswordForm:
|
||||
assert.Equal(t, "Change Password", doc.Find("h2").Text())
|
||||
assert.Equal(t, 1, doc.Find("input[name=password]").Length())
|
||||
assert.Equal(t, 1, doc.Find("input[name=passwordconfirm]").Length())
|
||||
assert.Equal(t, 1, doc.Find("input[value='Update User']").Length())
|
||||
assert.Equal(t, 1, doc.Find("input[value=Cancel]").Length())
|
||||
assert.Equal(t, "changepass", hidden.Filter("[name=do]").AttrOr("value", ""))
|
||||
assert.Equal(t, staff.Username, hidden.Filter("[name=username]").AttrOr("value", ""))
|
||||
case changeRankForm:
|
||||
assert.Equal(t, "Change User Rank", doc.Find("h2").Text())
|
||||
assert.Equal(t, 1, doc.Find("input[value='Update User']").Length())
|
||||
assert.Equal(t, 1, doc.Find("input[value=Cancel]").Length())
|
||||
hidden := doc.Find("input[type=hidden]")
|
||||
assert.Equal(t, "changerank", hidden.Filter("[name=do]").AttrOr("value", ""))
|
||||
assert.Equal(t, staff.Username, hidden.Filter("[name=username]").AttrOr("value", ""))
|
||||
case noForm:
|
||||
assert.Equal(t, 0, doc.Find("h2").Length())
|
||||
form := doc.Find("form[action='/manage/staff']")
|
||||
assert.Equal(t, 0, form.Length())
|
||||
}
|
||||
}
|
||||
|
||||
// manageCallbackTestCase is a generic test case struct for testing the callback functions for /manage/{action}
|
||||
type manageCallbackTestCase struct {
|
||||
desc string
|
||||
path string
|
||||
staff *gcsql.Staff
|
||||
method string
|
||||
header http.Header
|
||||
form url.Values
|
||||
wantsJSON bool
|
||||
expectError bool
|
||||
expectStatus int
|
||||
prepareMock func(t *testing.T, mock sqlmock.Sqlmock)
|
||||
validateOutput func(t *testing.T, output any, writer *httptest.ResponseRecorder, err error)
|
||||
}
|
||||
|
||||
func (tc *manageCallbackTestCase) runTest(t *testing.T, manageCallbackFunc CallbackFunction) {
|
||||
infoEv := gcutil.LogInfo()
|
||||
errEv := gcutil.LogError(nil)
|
||||
db, mock, err := sqlmock.New()
|
||||
if !assert.NoError(t, err) {
|
||||
t.FailNow()
|
||||
}
|
||||
defer db.Close()
|
||||
if !assert.NoError(t, gcsql.SetTestingDB("mysql", "gochan", "", db)) {
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(tc.method, "http://localhost"+tc.path, nil)
|
||||
if !assert.NoError(t, err) {
|
||||
t.FailNow()
|
||||
}
|
||||
if tc.staff == nil {
|
||||
tc.staff = &gcsql.Staff{}
|
||||
}
|
||||
maps.Copy(request.Header, tc.header)
|
||||
if tc.method == "POST" {
|
||||
request.PostForm = tc.form
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
} else {
|
||||
request.Form = tc.form
|
||||
}
|
||||
|
||||
if tc.prepareMock != nil {
|
||||
tc.prepareMock(t, mock)
|
||||
}
|
||||
|
||||
writer := httptest.NewRecorder()
|
||||
output, err := manageCallbackFunc(writer, request, tc.staff, tc.wantsJSON, infoEv, errEv)
|
||||
assert.Equal(t, tc.expectStatus, writer.Code)
|
||||
if tc.expectError {
|
||||
assert.Error(t, err)
|
||||
if tc.validateOutput != nil {
|
||||
tc.validateOutput(t, output, writer, err)
|
||||
}
|
||||
} else {
|
||||
if !assert.NoError(t, err) {
|
||||
t.FailNow()
|
||||
}
|
||||
if !assert.NoError(t, mock.ExpectationsWereMet()) {
|
||||
t.FailNow()
|
||||
}
|
||||
if tc.validateOutput == nil {
|
||||
t.Fatal("validateOutput is nil")
|
||||
}
|
||||
tc.validateOutput(t, output, writer, err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupManageTestSuite(t *testing.T) {
|
||||
config.InitConfig()
|
||||
|
||||
_, err := testutil.GoToGochanRoot(t)
|
||||
if !assert.NoError(t, err) {
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
systemCriticalConfig := config.GetSystemCriticalConfig()
|
||||
systemCriticalConfig.TemplateDir = "templates"
|
||||
systemCriticalConfig.SiteHost = "localhost"
|
||||
config.SetSystemCriticalConfig(systemCriticalConfig)
|
||||
|
||||
gctemplates.InitTemplates()
|
||||
|
||||
}
|
||||
|
||||
func TestLoginCallback(t *testing.T) {
|
||||
setupManageTestSuite(t)
|
||||
|
||||
for _, tc := range loginTestCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
tc.runTest(t, loginCallback)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffCallback(t *testing.T) {
|
||||
setupManageTestSuite(t)
|
||||
for _, tc := range staffTestCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
tc.runTest(t, staffCallback)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,180 +0,0 @@
|
|||
package manage
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"maps"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/gochan-org/gochan/pkg/config"
|
||||
"github.com/gochan-org/gochan/pkg/gcsql"
|
||||
"github.com/gochan-org/gochan/pkg/gctemplates"
|
||||
"github.com/gochan-org/gochan/pkg/gcutil"
|
||||
"github.com/gochan-org/gochan/pkg/gcutil/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
loginQueryRE = `SELECT\s*id,\s*username,\s*password_checksum,\s*global_rank,\s*added_on,\s*last_login,\s*is_active\s*FROM staff WHERE username = \? AND is_active = TRUE`
|
||||
insertSessionRE = `INSERT INTO sessions \(staff_id,data,expires\) VALUES\(\?,\?,\?\)`
|
||||
updateStaffLoginRE = `UPDATE staff SET last_login = CURRENT_TIMESTAMP WHERE id = \?`
|
||||
)
|
||||
|
||||
var (
|
||||
loginTestCases = []manageCallbackTestCase{
|
||||
{
|
||||
desc: "GET login",
|
||||
path: "/manage/login",
|
||||
method: "GET",
|
||||
expectStatus: http.StatusOK,
|
||||
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder) {
|
||||
if !assert.NotNil(t, output) {
|
||||
t.FailNow()
|
||||
}
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(output.(string)))
|
||||
if !assert.NoError(t, err) {
|
||||
t.FailNow()
|
||||
}
|
||||
assert.Equal(t, 1, doc.Find("input[name=username]").Length())
|
||||
assert.Equal(t, 1, doc.Find("input[name=password]").Length())
|
||||
assert.Equal(t, 1, doc.Find("input[value=Login]").Length())
|
||||
},
|
||||
}, {
|
||||
desc: "POST login",
|
||||
method: "POST",
|
||||
path: "/manage/login",
|
||||
header: http.Header{
|
||||
"Referer": []string{"http://localhost/manage/login"},
|
||||
},
|
||||
form: url.Values{
|
||||
"username": {"admin"},
|
||||
"password": {"password"},
|
||||
},
|
||||
expectStatus: http.StatusFound,
|
||||
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
mock.ExpectPrepare(loginQueryRE).ExpectQuery().WithArgs("admin").WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
|
||||
AddRow(1, "admin", "$2a$10$EdXlrHd/vKQo9COSpxRdgOpjzEQ7As5mW4N5P4R4KrqaI8j3jO2PW", 1, time.Now(), time.Now(), true),
|
||||
)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectPrepare(insertSessionRE).ExpectExec().WithArgs(1, sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectPrepare(updateStaffLoginRE).ExpectExec().WithArgs(1).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
},
|
||||
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder) {
|
||||
assert.Nil(t, output) // redirect, output is nil
|
||||
},
|
||||
}, {
|
||||
desc: "POST login with invalid credentials",
|
||||
method: "POST",
|
||||
path: "/manage/login",
|
||||
header: http.Header{
|
||||
"Referer": []string{"http://localhost/manage/login"},
|
||||
},
|
||||
form: url.Values{
|
||||
"username": {"admin"},
|
||||
"password": {"wrongpassword"},
|
||||
},
|
||||
expectStatus: http.StatusUnauthorized,
|
||||
expectError: true,
|
||||
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
mock.ExpectPrepare(loginQueryRE).ExpectQuery().WithArgs("admin").WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
|
||||
AddRow(1, "admin", "$2a$10$EdXlrHd/vKQo9COSpxRdgOpjzEQ7As5mW4N5P4R4KrqaI8j3jO2PW", 1, time.Now(), time.Now(), true),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// manageCallbackTestCase is a generic test case struct for testing the callback functions for /manage/{action}
|
||||
type manageCallbackTestCase struct {
|
||||
desc string
|
||||
// writer *httptest.ResponseRecorder
|
||||
path string
|
||||
staff *gcsql.Staff
|
||||
method string
|
||||
header http.Header
|
||||
form url.Values
|
||||
wantsJSON bool
|
||||
expectError bool
|
||||
expectStatus int
|
||||
prepareMock func(t *testing.T, mock sqlmock.Sqlmock)
|
||||
validateOutput func(t *testing.T, output any, writer *httptest.ResponseRecorder)
|
||||
}
|
||||
|
||||
func TestLoginCallback(t *testing.T) {
|
||||
config.InitConfig()
|
||||
|
||||
_, err := testutil.GoToGochanRoot(t)
|
||||
if !assert.NoError(t, err) {
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
systemCriticalConfig := config.GetSystemCriticalConfig()
|
||||
systemCriticalConfig.TemplateDir = "templates"
|
||||
systemCriticalConfig.SiteHost = "localhost"
|
||||
config.SetSystemCriticalConfig(systemCriticalConfig)
|
||||
|
||||
gctemplates.InitTemplates()
|
||||
|
||||
infoEv := gcutil.LogInfo()
|
||||
errEv := gcutil.LogError(nil)
|
||||
|
||||
for _, tc := range loginTestCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if !assert.NoError(t, err) {
|
||||
t.FailNow()
|
||||
}
|
||||
defer db.Close()
|
||||
if !assert.NoError(t, gcsql.SetTestingDB("mysql", "gochan", "", db)) {
|
||||
t.FailNow()
|
||||
}
|
||||
defer assert.NoError(t, mock.ExpectationsWereMet())
|
||||
|
||||
request, err := http.NewRequest(tc.method, "http://localhost"+tc.path, nil)
|
||||
if !assert.NoError(t, err) {
|
||||
t.FailNow()
|
||||
}
|
||||
if tc.staff == nil {
|
||||
tc.staff = &gcsql.Staff{}
|
||||
}
|
||||
maps.Copy(request.Header, tc.header)
|
||||
if tc.method == "POST" {
|
||||
request.PostForm = tc.form
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
} else {
|
||||
request.Form = tc.form
|
||||
}
|
||||
|
||||
if tc.prepareMock != nil {
|
||||
tc.prepareMock(t, mock)
|
||||
}
|
||||
|
||||
writer := httptest.NewRecorder()
|
||||
output, err := loginCallback(writer, request, tc.staff, tc.wantsJSON, infoEv, errEv)
|
||||
assert.Equal(t, tc.expectStatus, writer.Code)
|
||||
if tc.expectError {
|
||||
assert.Error(t, err)
|
||||
if tc.validateOutput != nil {
|
||||
tc.validateOutput(t, output, writer)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
if tc.validateOutput == nil {
|
||||
t.Fatal("validateOutput is nil")
|
||||
}
|
||||
tc.validateOutput(t, output, writer)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -16,24 +16,24 @@ var (
|
|||
router *bunrouter.Router
|
||||
)
|
||||
|
||||
type serverError struct {
|
||||
err any
|
||||
statusCode int
|
||||
type ServerError struct {
|
||||
Err any
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (e *serverError) Error() string {
|
||||
return fmt.Sprint(e.err)
|
||||
func (e *ServerError) Error() string {
|
||||
return fmt.Sprint(e.Err)
|
||||
}
|
||||
|
||||
func (e *serverError) Unwrap() error {
|
||||
if err, ok := e.err.(error); ok {
|
||||
func (e *ServerError) Unwrap() error {
|
||||
if err, ok := e.Err.(error); ok {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewServerError(message any, statusCode int) error {
|
||||
return &serverError{err: message, statusCode: statusCode}
|
||||
return &ServerError{Err: message, StatusCode: statusCode}
|
||||
}
|
||||
|
||||
// ServeJSON serves data as a JSON string
|
||||
|
@ -45,8 +45,8 @@ func ServeJSON(writer http.ResponseWriter, data map[string]any) {
|
|||
|
||||
// ServeErrorPage shows a general error page if something goes wrong
|
||||
func ServeErrorPage(writer http.ResponseWriter, err any) {
|
||||
if se, ok := err.(*serverError); ok {
|
||||
writer.WriteHeader(se.statusCode)
|
||||
if se, ok := err.(*ServerError); ok {
|
||||
writer.WriteHeader(se.StatusCode)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
serverutil.MinifyTemplate(gctemplates.ErrorPage, map[string]any{
|
||||
|
@ -68,8 +68,8 @@ func ServeError(writer http.ResponseWriter, err any, wantsJSON bool, data map[st
|
|||
servedMap = make(map[string]any)
|
||||
}
|
||||
servedMap["error"] = err
|
||||
if se, ok := err.(*serverError); ok {
|
||||
writer.WriteHeader(se.statusCode)
|
||||
if se, ok := err.(*ServerError); ok {
|
||||
writer.WriteHeader(se.StatusCode)
|
||||
}
|
||||
ServeJSON(writer, servedMap)
|
||||
} else {
|
||||
|
|
|
@ -1,57 +1,68 @@
|
|||
{{$isAdmin := (eq .currentStaff.Rank 3) -}}
|
||||
<table class="mgmt-table stafflist">
|
||||
<tr><th>Username</th><th>Rank</th><th>Added on</th><th>Action</th></tr>
|
||||
{{range $s, $staff := $.allstaff -}}
|
||||
<tr>
|
||||
<td>{{$staff.Username}}</td>
|
||||
<td>{{$staff.RankTitle}}</td>
|
||||
<td>{{formatTimestamp $staff.AddedOn}}</td>
|
||||
<td>
|
||||
{{if or $isAdmin (eq $staff.Username $.currentStaff.Username) -}}
|
||||
<a href="{{webPath `/manage/staff`}}?update={{$staff.Username}}">Update</a>
|
||||
{{end}}{{if and $isAdmin (not (eq $staff.Username $.currentStaff.Username)) -}}
|
||||
<a
|
||||
href="{{webPath `/manage/staff`}}?do=del&username={{$staff.Username}}"
|
||||
title="Delete {{$staff.Username}}"
|
||||
onclick="return confirm('Are you sure you want to delete the staff account for \'{{$staff.Username}}\'?')"
|
||||
style="color:red;">Delete</a>
|
||||
{{- end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{- define "rankRow" -}}
|
||||
<tr><th>{{if eq $.formType 3}}New {{end}}Rank</th><td>
|
||||
<select id="rank" name="rank">
|
||||
<option value="-1"{{if eq $.rank 0}}selected{{end}} disabled>Select one</option>
|
||||
<option value="3"{{if eq $.rank 3}}selected{{end}}>Admin</option>
|
||||
<option value="2"{{if eq $.rank 2}}selected{{end}}>Moderator</option>
|
||||
<option value="1"{{if eq $.rank 1}}selected{{end}}>Janitor</option>
|
||||
</select></td></tr>{{end -}}
|
||||
|
||||
{{- define "passwordRows" -}}
|
||||
<tr><th>Password:</th><td><input name="password" type="password" autocomplete="new-password" /></td></tr>
|
||||
<tr><th>Confirm password:</th><td><input id="passwordconfirm" name="passwordconfirm" type="password"/></td></tr>
|
||||
{{- end -}}
|
||||
|
||||
<table class="mgmt-table stafflist">
|
||||
<tr><th>Username</th><th>Rank</th><th>Added on</th><th>Action</th></tr>
|
||||
{{range $s, $staff := $.allstaff -}}
|
||||
<tr>
|
||||
<td>{{$staff.Username}}</td>
|
||||
<td>{{$staff.RankTitle}}</td>
|
||||
<td>{{formatTimestamp $staff.AddedOn}}</td>
|
||||
<td>
|
||||
{{- if or $isAdmin (eq $staff.Username $.currentStaff.Username) -}}
|
||||
<a href="{{webPath `/manage/staff`}}?changepass={{$staff.Username}}">Change Password</a>
|
||||
{{- end}}{{if $isAdmin}} | <a
|
||||
href="{{webPath `/manage/staff`}}?changerank={{$staff.Username}}">Change Rank</a>
|
||||
{{- end}}{{if and $isAdmin (not (eq $staff.Username $.currentStaff.Username))}} | <a
|
||||
href="{{webPath `/manage/staff`}}?do=del&username={{$staff.Username}}"
|
||||
title="Delete {{$staff.Username}}"
|
||||
onclick="return confirm('Are you sure you want to delete the staff account for \'{{$staff.Username}}\'?')"
|
||||
style="color:red;">Delete</a>
|
||||
{{- end -}}
|
||||
</td>
|
||||
</tr>
|
||||
{{- end -}}
|
||||
</table>
|
||||
{{- if gt $.formMode 0 -}}
|
||||
<hr />
|
||||
<h2>{{$.formMode}}</h2>
|
||||
{{- if eq $.formMode 2 -}}
|
||||
<p>If the password fields are left blank, only the rank will be updated</p>
|
||||
{{- end -}}
|
||||
<form action="{{webPath `/manage/staff`}}" method="POST" autocomplete="off">
|
||||
{{if lt $.formMode 3}}
|
||||
<input type="hidden" name="update" value="{{.updateUsername}}">
|
||||
<input type="hidden" name="do" value="update" />
|
||||
{{else}}
|
||||
<input type="hidden" name="do" value="add" />
|
||||
{{end}}
|
||||
<table>
|
||||
<tr><td>Username:</td><td><input name="username" type="text" autocomplete="new-password" value="{{if $isAdmin}}{{.updateUsername}}{{else}}{{.currentStaff.Username}}{{end}}" {{if lt $.formMode 3}}disabled{{end}}/></td></tr>
|
||||
<tr><td>Password:</td><td><input name="password" type="password" autocomplete="new-password" /></td></tr>
|
||||
<tr><td>Confirm password:</td><td><input id="passwordconfirm" name="passwordconfirm" type="password"/></td></tr>
|
||||
{{if gt $.formMode 1 -}}
|
||||
<tr><td>Rank:</td><td><select id="rank" name="rank">
|
||||
<option value="-1"{{if eq $.updateRank -1}}selected{{end}} disabled>Select one</option>
|
||||
<option value="3"{{if eq $.updateRank 3}}selected{{end}}>Admin</option>
|
||||
<option value="2"{{if eq $.updateRank 2}}selected{{end}}>Moderator</option>
|
||||
<option value="1"{{if eq $.updateRank 1}}selected{{end}}>Janitor</option>
|
||||
</select></td></tr>
|
||||
{{end -}}
|
||||
<tr><td>
|
||||
<input type="submit" value="{{if eq $.formMode 3}}Create{{else}}Update{{end}} User" />
|
||||
{{- if lt $.formMode 3 -}}
|
||||
<input type="hidden" name="username" value="{{.username}}" />
|
||||
{{- end -}}
|
||||
<table>
|
||||
<tr><th>Username</th><td>{{if lt $.formMode 3}}{{.username}}{{else}}<input type="text" name="username" value="{{.username}}"/>{{end}}</td></tr>
|
||||
{{- if eq $.formMode 1 -}}
|
||||
{{/* Change Password */}}
|
||||
<input type="hidden" name="do" value="changepass" />
|
||||
{{- template "passwordRows" . -}}
|
||||
{{- else if eq $.formMode 2 -}}
|
||||
{{/* Change Rank */}}
|
||||
<input type="hidden" name="do" value="changerank" />
|
||||
{{template "rankRow" .}}
|
||||
{{- else if eq $.formMode 3 -}}
|
||||
{{/* Add Staff */}}
|
||||
<input type="hidden" name="do" value="add" />
|
||||
{{- template "passwordRows" . -}}
|
||||
{{- template "rankRow" . -}}
|
||||
{{- end -}}
|
||||
<tr><td><input type="submit" value="{{if eq $.formMode 3}}Create{{else}}Update{{end}} User" />
|
||||
{{- if lt $.formMode 3 -}}
|
||||
<input type="button" name="docancel" value="Cancel" onclick="window.location = {{webPath `./manage/staff`}}; return false"/>
|
||||
{{- end -}}
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue