veza/veza-backend-api/internal/handlers/two_factor_handler_test.go
senke 24b29d229d fix(v0.12.6.1): remediate 2 CRITICAL + 10 HIGH + 1 MEDIUM pentest findings
Security fixes implemented:

CRITICAL:
- CRIT-001: IDOR on chat rooms — added IsRoomMember check before
  returning room data or message history (returns 404, not 403)
- CRIT-002: play_count/like_count exposed publicly — changed JSON
  tags to "-" so they are never serialized in API responses

HIGH:
- HIGH-001: TOCTOU race on marketplace downloads — transaction +
  SELECT FOR UPDATE on GetDownloadURL
- HIGH-002: HS256 in production docker-compose — replaced JWT_SECRET
  with JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH (RS256)
- HIGH-003: context.Background() bypass in user repository — full
  context propagation from handlers → services → repository (29 files)
- HIGH-004: Race condition on promo codes — SELECT FOR UPDATE
- HIGH-005: Race condition on exclusive licenses — SELECT FOR UPDATE
- HIGH-006: Rate limiter IP spoofing — SetTrustedProxies(nil) default
- HIGH-007: RGPD hard delete incomplete — added cleanup for sessions,
  settings, follows, notifications, audit_logs anonymization
- HIGH-008: RTMP callback auth weak — fail-closed when unconfigured,
  header-only (no query param), constant-time compare
- HIGH-009: Co-listening host hijack — UpdateHostState now takes *Conn
  and verifies IsHost before processing
- HIGH-010: Moderator self-strike — added issuedBy != userID check

MEDIUM:
- MEDIUM-001: Recovery codes used math/rand — replaced with crypto/rand
- MEDIUM-005: Stream token forgeable — resolved by HIGH-002 (RS256)

Updated REMEDIATION_MATRIX: 14 findings marked  CORRIGÉ.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:40:53 +01:00

379 lines
12 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"veza-backend-api/internal/models"
"veza-backend-api/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
// MockTwoFactorService mocks TwoFactorService
type MockTwoFactorService struct {
mock.Mock
}
func (m *MockTwoFactorService) GetTwoFactorStatus(ctx context.Context, userID uuid.UUID) (bool, error) {
args := m.Called(ctx, userID)
return args.Bool(0), args.Error(1)
}
func (m *MockTwoFactorService) GenerateSecret(user *models.User) (*services.TwoFactorSetup, error) {
args := m.Called(user)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*services.TwoFactorSetup), args.Error(1)
}
func (m *MockTwoFactorService) VerifyTOTPCode(secret, code string) bool {
args := m.Called(secret, code)
return args.Bool(0)
}
func (m *MockTwoFactorService) GenerateRecoveryCodes() []string {
args := m.Called()
return args.Get(0).([]string)
}
func (m *MockTwoFactorService) EnableTwoFactor(ctx context.Context, userID uuid.UUID, secret string, recoveryCodes []string) error {
args := m.Called(ctx, userID, secret, recoveryCodes)
return args.Error(0)
}
func (m *MockTwoFactorService) DisableTwoFactor(ctx context.Context, userID uuid.UUID) error {
args := m.Called(ctx, userID)
return args.Error(0)
}
// MockUserService mocks UserService
type MockUserService struct {
mock.Mock
}
func (m *MockUserService) GetByID(_ context.Context, userID uuid.UUID) (*models.User, error) {
args := m.Called(userID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func setupTestTwoFactorRouter(mockTwoFactorService *MockTwoFactorService, mockUserService *MockUserService) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
logger := zap.NewNop()
handler := NewTwoFactorHandlerWithInterface(mockTwoFactorService, mockUserService, logger)
api := router.Group("/api/v1/auth/2fa")
api.Use(func(c *gin.Context) {
userIDStr := c.GetHeader("X-User-ID")
if userIDStr != "" {
uid, err := uuid.Parse(userIDStr)
if err == nil {
c.Set("user_id", uid)
}
}
c.Next()
})
{
api.POST("/setup", handler.SetupTwoFactor)
api.POST("/verify", handler.VerifyTwoFactor)
api.POST("/disable", handler.DisableTwoFactor)
api.GET("/status", handler.GetTwoFactorStatus)
}
return router
}
func TestTwoFactorHandler_SetupTwoFactor_Success(t *testing.T) {
// Setup
mockTwoFactorService := new(MockTwoFactorService)
mockUserService := new(MockUserService)
router := setupTestTwoFactorRouter(mockTwoFactorService, mockUserService)
userID := uuid.New()
mockUser := &models.User{
ID: userID,
Email: "test@example.com",
Username: "testuser",
}
mockSetup := &services.TwoFactorSetup{
Secret: "TEST_SECRET",
QRCodeURL: "otpauth://totp/Veza:test@example.com?secret=TEST_SECRET",
RecoveryCodes: []string{"CODE1", "CODE2"},
}
mockTwoFactorService.On("GetTwoFactorStatus", mock.Anything, userID).Return(false, nil)
mockUserService.On("GetByID", userID).Return(mockUser, nil)
mockTwoFactorService.On("GenerateSecret", mockUser).Return(mockSetup, nil)
// Execute
req, _ := http.NewRequest("POST", "/api/v1/auth/2fa/setup", nil)
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.True(t, response["success"].(bool))
mockTwoFactorService.AssertExpectations(t)
mockUserService.AssertExpectations(t)
}
func TestTwoFactorHandler_SetupTwoFactor_AlreadyEnabled(t *testing.T) {
// Setup
mockTwoFactorService := new(MockTwoFactorService)
mockUserService := new(MockUserService)
router := setupTestTwoFactorRouter(mockTwoFactorService, mockUserService)
userID := uuid.New()
mockTwoFactorService.On("GetTwoFactorStatus", mock.Anything, userID).Return(true, nil)
// Execute
req, _ := http.NewRequest("POST", "/api/v1/auth/2fa/setup", nil)
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusBadRequest, w.Code)
mockTwoFactorService.AssertNotCalled(t, "GenerateSecret")
mockUserService.AssertNotCalled(t, "GetByID")
}
func TestTwoFactorHandler_SetupTwoFactor_Unauthorized(t *testing.T) {
// Setup
mockTwoFactorService := new(MockTwoFactorService)
mockUserService := new(MockUserService)
router := setupTestTwoFactorRouter(mockTwoFactorService, mockUserService)
// Execute - No X-User-ID header
req, _ := http.NewRequest("POST", "/api/v1/auth/2fa/setup", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.True(t, w.Code == http.StatusUnauthorized || w.Code == http.StatusForbidden)
mockTwoFactorService.AssertNotCalled(t, "GetTwoFactorStatus")
}
func TestTwoFactorHandler_VerifyTwoFactor_Success(t *testing.T) {
// Setup
mockTwoFactorService := new(MockTwoFactorService)
mockUserService := new(MockUserService)
router := setupTestTwoFactorRouter(mockTwoFactorService, mockUserService)
userID := uuid.New()
reqBody := VerifyTwoFactorRequest{
Secret: "TEST_SECRET",
Code: "123456",
}
recoveryCodes := []string{"CODE1", "CODE2"}
mockTwoFactorService.On("GetTwoFactorStatus", mock.Anything, userID).Return(false, nil)
mockTwoFactorService.On("VerifyTOTPCode", "TEST_SECRET", "123456").Return(true)
mockTwoFactorService.On("GenerateRecoveryCodes").Return(recoveryCodes)
mockTwoFactorService.On("EnableTwoFactor", mock.Anything, userID, "TEST_SECRET", recoveryCodes).Return(nil)
body, _ := json.Marshal(reqBody)
// Execute
req, _ := http.NewRequest("POST", "/api/v1/auth/2fa/verify", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
mockTwoFactorService.AssertExpectations(t)
}
func TestTwoFactorHandler_VerifyTwoFactor_InvalidCode(t *testing.T) {
// Setup
mockTwoFactorService := new(MockTwoFactorService)
mockUserService := new(MockUserService)
router := setupTestTwoFactorRouter(mockTwoFactorService, mockUserService)
userID := uuid.New()
reqBody := VerifyTwoFactorRequest{
Secret: "TEST_SECRET",
Code: "000000",
}
mockTwoFactorService.On("GetTwoFactorStatus", mock.Anything, userID).Return(false, nil)
mockTwoFactorService.On("VerifyTOTPCode", "TEST_SECRET", "000000").Return(false)
body, _ := json.Marshal(reqBody)
// Execute
req, _ := http.NewRequest("POST", "/api/v1/auth/2fa/verify", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusBadRequest, w.Code)
mockTwoFactorService.AssertNotCalled(t, "EnableTwoFactor")
}
func TestTwoFactorHandler_DisableTwoFactor_Success(t *testing.T) {
// Setup
mockTwoFactorService := new(MockTwoFactorService)
mockUserService := new(MockUserService)
router := setupTestTwoFactorRouter(mockTwoFactorService, mockUserService)
userID := uuid.New()
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
user := &models.User{ID: userID, PasswordHash: string(hashedPassword)}
reqBody := DisableTwoFactorRequest{
Password: "password123",
}
mockTwoFactorService.On("GetTwoFactorStatus", mock.Anything, userID).Return(true, nil)
mockUserService.On("GetByID", userID).Return(user, nil)
mockTwoFactorService.On("DisableTwoFactor", mock.Anything, userID).Return(nil)
body, _ := json.Marshal(reqBody)
// Execute
req, _ := http.NewRequest("POST", "/api/v1/auth/2fa/disable", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
mockTwoFactorService.AssertExpectations(t)
}
func TestTwoFactorHandler_DisableTwoFactor_NotEnabled(t *testing.T) {
// Setup
mockTwoFactorService := new(MockTwoFactorService)
mockUserService := new(MockUserService)
router := setupTestTwoFactorRouter(mockTwoFactorService, mockUserService)
userID := uuid.New()
reqBody := DisableTwoFactorRequest{
Password: "password123",
}
mockTwoFactorService.On("GetTwoFactorStatus", mock.Anything, userID).Return(false, nil)
body, _ := json.Marshal(reqBody)
// Execute
req, _ := http.NewRequest("POST", "/api/v1/auth/2fa/disable", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusBadRequest, w.Code)
mockTwoFactorService.AssertNotCalled(t, "DisableTwoFactor")
}
func TestTwoFactorHandler_DisableTwoFactor_InvalidPassword(t *testing.T) {
// Setup
mockTwoFactorService := new(MockTwoFactorService)
mockUserService := new(MockUserService)
router := setupTestTwoFactorRouter(mockTwoFactorService, mockUserService)
userID := uuid.New()
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost)
user := &models.User{ID: userID, PasswordHash: string(hashedPassword)}
reqBody := DisableTwoFactorRequest{
Password: "wrongpassword",
}
mockTwoFactorService.On("GetTwoFactorStatus", mock.Anything, userID).Return(true, nil)
mockUserService.On("GetByID", userID).Return(user, nil)
body, _ := json.Marshal(reqBody)
// Execute
req, _ := http.NewRequest("POST", "/api/v1/auth/2fa/disable", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusBadRequest, w.Code)
mockTwoFactorService.AssertNotCalled(t, "DisableTwoFactor")
}
func TestTwoFactorHandler_DisableTwoFactor_MissingPassword(t *testing.T) {
// Setup
mockTwoFactorService := new(MockTwoFactorService)
mockUserService := new(MockUserService)
router := setupTestTwoFactorRouter(mockTwoFactorService, mockUserService)
userID := uuid.New()
reqBody := DisableTwoFactorRequest{
Password: "",
}
body, _ := json.Marshal(reqBody)
// Execute
req, _ := http.NewRequest("POST", "/api/v1/auth/2fa/disable", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert - binding:"required" should reject empty password
assert.Equal(t, http.StatusBadRequest, w.Code)
mockTwoFactorService.AssertNotCalled(t, "EnableTwoFactor")
mockTwoFactorService.AssertNotCalled(t, "DisableTwoFactor")
}
func TestTwoFactorHandler_GetTwoFactorStatus_Success(t *testing.T) {
// Setup
mockTwoFactorService := new(MockTwoFactorService)
mockUserService := new(MockUserService)
router := setupTestTwoFactorRouter(mockTwoFactorService, mockUserService)
userID := uuid.New()
mockTwoFactorService.On("GetTwoFactorStatus", mock.Anything, userID).Return(true, nil)
// Execute
req, _ := http.NewRequest("GET", "/api/v1/auth/2fa/status", nil)
req.Header.Set("X-User-ID", userID.String())
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.True(t, response["success"].(bool))
data := response["data"].(map[string]interface{})
assert.True(t, data["enabled"].(bool))
mockTwoFactorService.AssertExpectations(t)
}