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() } }