package v1 import ( "bytes" "crypto/hmac" "crypto/sha1" "encoding/base32" "encoding/binary" "encoding/json" "fmt" "math" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/pkg/response" ) // --- MFA Handler Test Suite --- // Uses real SQLite DB + real MFAService + httptest. type MFAHandlerTestSuite struct { suite.Suite db *gorm.DB router *gin.Engine handler *MFAHandler mfaService *auth.MFAService user *model.User account *model.Account userID uint accountID uint } func TestMFAHandlerSuite(t *testing.T) { suite.Run(t, new(MFAHandlerTestSuite)) } func (s *MFAHandlerTestSuite) SetupSuite() { gin.SetMode(gin.TestMode) // Create in-memory SQLite DB db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) s.Require().NoError(err, "failed to open SQLite test database") // Migrate models needed for MFA operations s.Require().NoError(db.AutoMigrate( &model.Account{}, &model.User{}, )) s.db = db // Create real MFA service backed by the test DB s.mfaService = auth.NewMFAService(db) s.handler = NewMFAHandler(s.mfaService) // Create test account account := &model.Account{Name: "MFATestAccount"} s.Require().NoError(db.Create(account).Error) s.account = account s.accountID = account.ID // Create test user belonging to the account user := &model.User{ AccountID: account.ID, Name: "MFA Test User", Email: "mfatest@example.com", Password: "hashedpassword123", Provider: "email", Role: "agent", Active: true, } s.Require().NoError(db.Create(user).Error) s.user = user s.userID = user.ID // Setup router with middleware that injects user_id into context s.setupRouter(s.userID) } func (s *MFAHandlerTestSuite) setupRouter(userID uint) { r := gin.New() r.Use(func(c *gin.Context) { c.Set("user_id", userID) c.Next() }) mfaGroup := r.Group("/api/v1/auth/mfa") { mfaGroup.POST("/enable", s.handler.EnableMFA) mfaGroup.POST("/verify", s.handler.VerifyMFA) mfaGroup.POST("/disable", s.handler.DisableMFA) } s.router = r } func (s *MFAHandlerTestSuite) SetupTest() { // Hard cleanup: delete all users and accounts, then recreate s.db.Exec("DELETE FROM users") s.db.Exec("DELETE FROM accounts") // Recreate test data account := &model.Account{Name: "MFATestAccount"} s.Require().NoError(s.db.Create(account).Error) s.account = account s.accountID = account.ID user := &model.User{ AccountID: account.ID, Name: "MFA Test User", Email: "mfatest@example.com", Password: "hashedpassword123", Provider: "email", Role: "agent", Active: true, } s.Require().NoError(s.db.Create(user).Error) s.user = user s.userID = user.ID // Re-setup router with the new user ID s.setupRouter(s.userID) } func (s *MFAHandlerTestSuite) TearDownSuite() { if s.db != nil { sqlDB, err := s.db.DB() if err == nil { sqlDB.Close() } } } // --- Helper to make requests and parse responses --- func (s *MFAHandlerTestSuite) doRequest(method, path, body string) *httptest.ResponseRecorder { var reqBody *bytes.Buffer if body != "" { reqBody = bytes.NewBufferString(body) } else { reqBody = bytes.NewBufferString("") } w := httptest.NewRecorder() req := httptest.NewRequest(method, path, reqBody) if body != "" { req.Header.Set("Content-Type", "application/json") } s.router.ServeHTTP(w, req) return w } func (s *MFAHandlerTestSuite) parseResponse(w *httptest.ResponseRecorder) response.APIResponse { var resp response.APIResponse s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) return resp } // --- Helper to generate a valid TOTP code for a secret --- // Uses the same algorithm as auth.validateTOTP/generateTOTP to compute a valid code. func (s *MFAHandlerTestSuite) generateValidTOTPCode(secret string) string { cfg := auth.DefaultTOTPConfig() return computeTOTPCode(secret, cfg) } func computeTOTPCode(secret string, cfg auth.TOTPConfig) string { key, err := decodeBase32NoPad(secret) if err != nil { return "" } now := time.Now().Unix() period := int64(cfg.Period) timeCounter := now / period return generateTOTPFromKey(key, timeCounter, cfg) } func decodeBase32NoPad(secret string) ([]byte, error) { return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret)) } func generateTOTPFromKey(key []byte, timeCounter int64, cfg auth.TOTPConfig) string { // Encode time counter as 8-byte big-endian buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, uint64(timeCounter)) // HMAC-SHA1 h := hmac.New(sha1.New, key) h.Write(buf) hash := h.Sum(nil) // Dynamic truncation per RFC 4226 offset := hash[len(hash)-1] & 0x0f truncated := (int32(hash[offset]&0x7f) << 24) | (int32(hash[offset+1]&0xff) << 16) | (int32(hash[offset+2]&0xff) << 8) | (int32(hash[offset+3]&0xff)) // Modulo 10^digits mod := int32(math.Pow10(cfg.Digits)) code := truncated % mod // Format with leading zeros return fmt.Sprintf("%0*d", cfg.Digits, code) } func jsonBody(data map[string]interface{}) string { b, err := json.Marshal(data) if err != nil { return "" } return string(b) } // ============================================================ // EnableMFA tests // ============================================================ func (s *MFAHandlerTestSuite) TestEnable_Success() { w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/enable", "{}") s.Equal(http.StatusOK, w.Code) resp := s.parseResponse(w) s.True(resp.Success) dataMap, ok := resp.Data.(map[string]interface{}) s.True(ok) // Response should contain totp_secret and qr_uri s.NotEmpty(dataMap["totp_secret"]) s.NotEmpty(dataMap["qr_uri"]) s.Contains(dataMap["qr_uri"], "otpauth://totp/") s.Contains(dataMap["qr_uri"], dataMap["totp_secret"]) } func (s *MFAHandlerTestSuite) TestEnable_NoBody() { // EnableMFARequest has no required fields, empty body should still work // since the handler doesn't even call ShouldBindJSON w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/enable", "") // With empty body and no Content-Type, the handler doesn't bind JSON, // so it just uses user_id from context → should succeed s.Equal(http.StatusOK, w.Code) resp := s.parseResponse(w) s.True(resp.Success) } func (s *MFAHandlerTestSuite) TestEnable_InvalidJSON() { // Enable handler does NOT call ShouldBindJSON at all — it only uses // c.GetUint("user_id") and service calls. So invalid JSON in the body // won't cause a binding error. With a valid user_id in context, // this should succeed regardless of body content. w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/enable", "{invalid}") // The handler ignores the body entirely, so with valid user_id it succeeds s.Equal(http.StatusOK, w.Code) } func (s *MFAHandlerTestSuite) TestEnable_Unauthorized_NoUserID() { // Create router without user_id middleware — user_id will be 0 r := gin.New() r.POST("/api/v1/auth/mfa/enable", s.handler.EnableMFA) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/mfa/enable", bytes.NewBufferString("{}")) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) s.Equal(http.StatusUnauthorized, w.Code) var respStruct response.APIResponse s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) s.False(respStruct.Success) s.NotNil(respStruct.Error) s.Equal(response.ErrUnauthorized, respStruct.Error.Code) } func (s *MFAHandlerTestSuite) TestEnable_UserNotFound() { // Router that sets a non-existent user ID r := gin.New() r.Use(func(c *gin.Context) { c.Set("user_id", uint(9999)) // non-existent user c.Next() }) r.POST("/api/v1/auth/mfa/enable", s.handler.EnableMFA) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/mfa/enable", bytes.NewBufferString("{}")) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) // IsMFAEnabled will fail to find the user → 500 Internal Server Error s.Equal(http.StatusUnprocessableEntity, w.Code) var respStruct response.APIResponse s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) s.False(respStruct.Success) s.NotNil(respStruct.Error) s.Equal(response.ErrInternal, respStruct.Error.Code) } func (s *MFAHandlerTestSuite) TestEnable_AlreadyEnabled() { // First enable MFA for the user user := s.user user.TOTPSecret = "JBSWY3DPEHPK3PXP" user.TOTPEnabled = true s.Require().NoError(s.db.Save(user).Error) w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/enable", "{}") s.Equal(http.StatusConflict, w.Code) resp := s.parseResponse(w) s.False(resp.Success) s.NotNil(resp.Error) s.Equal(response.ErrConflict, resp.Error.Code) } // ============================================================ // VerifyMFA tests // ============================================================ func (s *MFAHandlerTestSuite) TestVerify_Success() { // Step 1: Generate secret secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) s.Require().NoError(err) // Step 2: Compute a valid TOTP code for the secret code := s.generateValidTOTPCode(secret) // Step 3: Verify with secret + code body := jsonBody(map[string]interface{}{ "totp_secret": secret, "totp_code": code, }) w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/verify", body) s.Equal(http.StatusOK, w.Code) resp := s.parseResponse(w) s.True(resp.Success) dataMap, ok := resp.Data.(map[string]interface{}) s.True(ok) s.Equal("MFA enabled successfully", dataMap["message"]) s.Equal(true, dataMap["mfa_enabled"]) // Verify that TOTPEnabled is now true in DB var updatedUser model.User s.Require().NoError(s.db.First(&updatedUser, s.userID).Error) s.True(updatedUser.TOTPEnabled) s.Equal(secret, updatedUser.TOTPSecret) } func (s *MFAHandlerTestSuite) TestVerify_InvalidJSON() { w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/verify", "{invalid}") s.Equal(http.StatusBadRequest, w.Code) resp := s.parseResponse(w) s.False(resp.Success) s.NotNil(resp.Error) s.Equal(response.ErrValidation, resp.Error.Code) } func (s *MFAHandlerTestSuite) TestVerify_MissingTOTPSecret() { // When totp_secret is missing from JSON, ShouldBindJSON fails with // binding:"required" validation error → handler returns ErrValidation body := jsonBody(map[string]interface{}{ "totp_code": "123456", }) w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/verify", body) // Missing required binding field → ShouldBindJSON error → 400 VALIDATION_ERROR s.Equal(http.StatusBadRequest, w.Code) resp := s.parseResponse(w) s.False(resp.Success) s.NotNil(resp.Error) s.Equal(response.ErrValidation, resp.Error.Code) } func (s *MFAHandlerTestSuite) TestVerify_MissingTOTPCode() { secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) s.Require().NoError(err) // When totp_code is missing from JSON, ShouldBindJSON fails with // binding:"required" validation error → handler returns ErrValidation body := jsonBody(map[string]interface{}{ "totp_secret": secret, }) w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/verify", body) // Missing required binding field → ShouldBindJSON error → 400 VALIDATION_ERROR s.Equal(http.StatusBadRequest, w.Code) resp := s.parseResponse(w) s.False(resp.Success) s.NotNil(resp.Error) s.Equal(response.ErrValidation, resp.Error.Code) } func (s *MFAHandlerTestSuite) TestVerify_InvalidTOTPCode() { secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) s.Require().NoError(err) body := jsonBody(map[string]interface{}{ "totp_secret": secret, "totp_code": "000000", // definitely wrong code }) w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/verify", body) // ValidateTOTPCode returns false → 400 Bad Request s.Equal(http.StatusBadRequest, w.Code) resp := s.parseResponse(w) s.False(resp.Success) s.NotNil(resp.Error) s.Equal(response.ErrBadRequest, resp.Error.Code) } func (s *MFAHandlerTestSuite) TestVerify_Unauthorized_NoUserID() { r := gin.New() r.POST("/api/v1/auth/mfa/verify", s.handler.VerifyMFA) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/mfa/verify", bytes.NewBufferString(`{"totp_secret":"abc","totp_code":"123456"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) s.Equal(http.StatusUnauthorized, w.Code) var respStruct response.APIResponse s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) s.False(respStruct.Success) s.Equal(response.ErrUnauthorized, respStruct.Error.Code) } // ============================================================ // DisableMFA tests // ============================================================ func (s *MFAHandlerTestSuite) TestDisable_Success() { // First, enable MFA for the user so we can disable it secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) s.Require().NoError(err) // Enable TOTP via service directly s.Require().NoError(s.mfaService.EnableTOTP(s.userID, secret)) // Now the user has TOTP enabled. Generate a current valid code for disable. disableCode := s.generateValidTOTPCode(secret) body := jsonBody(map[string]interface{}{ "totp_code": disableCode, }) w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/disable", body) s.Equal(http.StatusOK, w.Code) resp := s.parseResponse(w) s.True(resp.Success) dataMap, ok := resp.Data.(map[string]interface{}) s.True(ok) s.Equal("MFA disabled successfully", dataMap["message"]) s.Equal(false, dataMap["mfa_enabled"]) // Verify that TOTPEnabled is now false in DB var updatedUser model.User s.Require().NoError(s.db.First(&updatedUser, s.userID).Error) s.False(updatedUser.TOTPEnabled) s.Empty(updatedUser.TOTPSecret) } func (s *MFAHandlerTestSuite) TestDisable_InvalidJSON() { w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/disable", "{invalid}") s.Equal(http.StatusBadRequest, w.Code) resp := s.parseResponse(w) s.False(resp.Success) s.NotNil(resp.Error) s.Equal(response.ErrValidation, resp.Error.Code) } func (s *MFAHandlerTestSuite) TestDisable_MissingTOTPCode() { // First enable MFA secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) s.Require().NoError(err) s.Require().NoError(s.mfaService.EnableTOTP(s.userID, secret)) // When totp_code is missing from JSON, ShouldBindJSON fails with // binding:"required" validation error → handler returns ErrValidation body := jsonBody(map[string]interface{}{}) w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/disable", body) // Missing required binding field → ShouldBindJSON error → 400 VALIDATION_ERROR s.Equal(http.StatusBadRequest, w.Code) resp := s.parseResponse(w) s.False(resp.Success) s.NotNil(resp.Error) s.Equal(response.ErrValidation, resp.Error.Code) } func (s *MFAHandlerTestSuite) TestDisable_InvalidTOTPCode() { // First enable MFA secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) s.Require().NoError(err) s.Require().NoError(s.mfaService.EnableTOTP(s.userID, secret)) body := jsonBody(map[string]interface{}{ "totp_code": "000000", // definitely wrong }) w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/disable", body) // DisableTOTP → VerifyTOTPCode → invalid code → error → 400 Bad Request s.Equal(http.StatusBadRequest, w.Code) resp := s.parseResponse(w) s.False(resp.Success) s.NotNil(resp.Error) s.Equal(response.ErrBadRequest, resp.Error.Code) } func (s *MFAHandlerTestSuite) TestDisable_Unauthorized_NoUserID() { r := gin.New() r.POST("/api/v1/auth/mfa/disable", s.handler.DisableMFA) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/mfa/disable", bytes.NewBufferString(`{"totp_code":"123456"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) s.Equal(http.StatusUnauthorized, w.Code) var respStruct response.APIResponse s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) s.False(respStruct.Success) s.Equal(response.ErrUnauthorized, respStruct.Error.Code) } func (s *MFAHandlerTestSuite) TestDisable_MFANotEnabled() { // User does not have MFA enabled — DisableTOTP calls VerifyTOTPCode // which checks user.TOTPEnabled == false → error body := jsonBody(map[string]interface{}{ "totp_code": "123456", }) w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/disable", body) // VerifyTOTPCode will return error "mfa not enabled for user" → 400 Bad Request s.Equal(http.StatusBadRequest, w.Code) resp := s.parseResponse(w) s.False(resp.Success) s.NotNil(resp.Error) s.Equal(response.ErrBadRequest, resp.Error.Code) } // ============================================================ // MFAStatus tests (bonus coverage for the status endpoint) // ============================================================ func (s *MFAHandlerTestSuite) TestStatus_MFADisabled() { // Setup router with status route r := gin.New() r.Use(func(c *gin.Context) { c.Set("user_id", s.userID) c.Next() }) r.GET("/api/v1/auth/mfa/status", s.handler.MFAStatus) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/mfa/status", nil) r.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) var respStruct response.APIResponse s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) s.True(respStruct.Success) dataMap, ok := respStruct.Data.(map[string]interface{}) s.True(ok) s.Equal(false, dataMap["mfa_enabled"]) } func (s *MFAHandlerTestSuite) TestStatus_MFAEnabled() { // Enable MFA first secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) s.Require().NoError(err) s.Require().NoError(s.mfaService.EnableTOTP(s.userID, secret)) // Setup router with status route r := gin.New() r.Use(func(c *gin.Context) { c.Set("user_id", s.userID) c.Next() }) r.GET("/api/v1/auth/mfa/status", s.handler.MFAStatus) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/mfa/status", nil) r.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) var respStruct response.APIResponse s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) s.True(respStruct.Success) dataMap, ok := respStruct.Data.(map[string]interface{}) s.True(ok) s.Equal(true, dataMap["mfa_enabled"]) } func (s *MFAHandlerTestSuite) TestStatus_Unauthorized_NoUserID() { r := gin.New() r.GET("/api/v1/auth/mfa/status", s.handler.MFAStatus) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/mfa/status", nil) r.ServeHTTP(w, req) s.Equal(http.StatusUnauthorized, w.Code) var respStruct response.APIResponse s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) s.False(respStruct.Success) s.Equal(response.ErrUnauthorized, respStruct.Error.Code) } // Ensure unused import warning doesn't cause issues var _ = assert.Equal