207 lines
7.3 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.Set(adminmiddleware.ContextPermissions, []string{"external-admin-user:permissions"})
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() }
}