206 lines
7.2 KiB
Go
206 lines
7.2 KiB
Go
package externaladmin
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"hyapp-admin-server/internal/appctx"
|
|
adminmiddleware "hyapp-admin-server/internal/middleware"
|
|
"hyapp-admin-server/internal/security"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/driver/mysql"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func TestChangePasswordRejectsCurrentPasswordWithoutClearingGateOrRevokingSessions(t *testing.T) {
|
|
service, mock, cleanup := newPasswordReuseFixture(t, "initial-password")
|
|
defer cleanup()
|
|
|
|
err := service.ChangePassword(t.Context(), SessionPrincipal{
|
|
AccountID: 7, SessionID: 21, AppCode: "fami",
|
|
}, ChangePasswordInput{CurrentPassword: "initial-password", NewPassword: "initial-password"})
|
|
if !errors.Is(err, ErrPasswordReused) {
|
|
t.Fatalf("change password error = %v, want ErrPasswordReused", err)
|
|
}
|
|
// The mock intentionally has no UPDATE expectation. Any attempt to clear
|
|
// password_change_required or revoke sessions makes the transaction/test fail.
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("password reuse performed a write: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestChangePasswordHandlerMapsPasswordReuseToExplicitBadRequest(t *testing.T) {
|
|
service, mock, cleanup := newPasswordReuseFixture(t, "initial-password")
|
|
defer cleanup()
|
|
handler := &Handler{service: service}
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
engine := gin.New()
|
|
engine.POST("/change-password", func(c *gin.Context) {
|
|
c.Set(contextPrincipal, SessionPrincipal{AccountID: 7, SessionID: 21, AppCode: "fami"})
|
|
c.Next()
|
|
}, handler.ChangePassword)
|
|
body := bytes.NewBufferString(`{"currentPassword":"initial-password","newPassword":"initial-password"}`)
|
|
request := httptest.NewRequest(http.MethodPost, "/change-password", body)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
responseRecorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(responseRecorder, request)
|
|
|
|
if responseRecorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
|
|
}
|
|
var responseBody struct {
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.Unmarshal(responseRecorder.Body.Bytes(), &responseBody); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if responseBody.Message != "新密码不能与当前密码相同" {
|
|
t.Fatalf("message = %q", responseBody.Message)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("password reuse handler performed a write: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAllPasswordWritersRejectWhitespaceOnlyCredential(t *testing.T) {
|
|
// A non-nil inert DB is enough because all three methods must reject the shared
|
|
// validation error before any App/user lookup or database write can run.
|
|
service := NewService(&gorm.DB{}, nil, Config{})
|
|
tests := []struct {
|
|
name string
|
|
call func() error
|
|
}{
|
|
{
|
|
name: "create",
|
|
call: func() error {
|
|
_, err := service.CreateAccount(t.Context(), CreateInput{
|
|
AppCode: "fami", TargetUserID: "123456", Username: "operator", Password: " ", CreatedByAdminID: 1,
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "reset",
|
|
call: func() error {
|
|
_, err := service.ResetPassword(t.Context(), "fami", 7, " ")
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "change",
|
|
call: func() error {
|
|
return service.ChangePassword(t.Context(), SessionPrincipal{AccountID: 7, SessionID: 21, AppCode: "fami"}, ChangePasswordInput{
|
|
CurrentPassword: "initial-password", NewPassword: " ",
|
|
})
|
|
},
|
|
},
|
|
}
|
|
for _, testCase := range tests {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
if err := testCase.call(); !errors.Is(err, ErrPasswordBlank) {
|
|
t.Fatalf("error = %v, want ErrPasswordBlank", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPasswordHandlersReturnExplicitWhitespaceValidationMessage(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
body string
|
|
wantMessage string
|
|
register func(*gin.Engine, *Handler)
|
|
}{
|
|
{
|
|
name: "create", path: "/create", wantMessage: "密码不能全部为空白字符",
|
|
body: `{"targetUserId":"123456","username":"operator","password":" "}`,
|
|
register: func(engine *gin.Engine, handler *Handler) {
|
|
engine.POST("/create", func(c *gin.Context) {
|
|
c.Set(adminmiddleware.ContextUserID, uint(1))
|
|
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
|
|
c.Next()
|
|
}, handler.CreateAccount)
|
|
},
|
|
},
|
|
{
|
|
name: "reset", path: "/reset/7", wantMessage: "密码不能全部为空白字符",
|
|
body: `{"password":" "}`,
|
|
register: func(engine *gin.Engine, handler *Handler) {
|
|
engine.POST("/reset/:id", func(c *gin.Context) {
|
|
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
|
|
c.Next()
|
|
}, handler.ResetPassword)
|
|
},
|
|
},
|
|
{
|
|
name: "change", path: "/change", wantMessage: "新密码不能全部为空白字符",
|
|
body: `{"currentPassword":"initial-password","newPassword":" "}`,
|
|
register: func(engine *gin.Engine, handler *Handler) {
|
|
engine.POST("/change", func(c *gin.Context) {
|
|
c.Set(contextPrincipal, SessionPrincipal{AccountID: 7, SessionID: 21, AppCode: "fami"})
|
|
c.Next()
|
|
}, handler.ChangePassword)
|
|
},
|
|
},
|
|
}
|
|
for _, testCase := range tests {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
handler := &Handler{service: NewService(&gorm.DB{}, nil, Config{})}
|
|
engine := gin.New()
|
|
testCase.register(engine, handler)
|
|
request := httptest.NewRequest(http.MethodPost, testCase.path, bytes.NewBufferString(testCase.body))
|
|
request.Header.Set("Content-Type", "application/json")
|
|
responseRecorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(responseRecorder, request)
|
|
if responseRecorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
|
|
}
|
|
var responseBody struct {
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.Unmarshal(responseRecorder.Body.Bytes(), &responseBody); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if responseBody.Message != testCase.wantMessage {
|
|
t.Fatalf("message = %q, want %q", responseBody.Message, testCase.wantMessage)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newPasswordReuseFixture(t *testing.T, currentPassword string) (*Service, sqlmock.Sqlmock, func()) {
|
|
t.Helper()
|
|
sqlDB, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("create sql mock: %v", err)
|
|
}
|
|
adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
|
if err != nil {
|
|
sqlDB.Close()
|
|
t.Fatalf("create gorm db: %v", err)
|
|
}
|
|
passwordHash, err := security.HashPassword(currentPassword)
|
|
if err != nil {
|
|
sqlDB.Close()
|
|
t.Fatalf("hash fixture password: %v", err)
|
|
}
|
|
mock.ExpectBegin()
|
|
mock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?").
|
|
WithArgs(uint64(7), "fami", 1).
|
|
WillReturnRows(sqlmock.NewRows([]string{
|
|
"id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status",
|
|
"password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms",
|
|
}).AddRow(7, "fami", 9001, "operator", passwordHash, `[]`, "active", true, 0, 0, 1, 1, 1))
|
|
mock.ExpectCommit()
|
|
return NewService(adminDB, nil, Config{}), mock, func() { _ = sqlDB.Close() }
|
|
}
|