veza/veza-backend-api/internal/core/analytics/handler_test.go
senke 9cd0da0046 fix(v0.12.6): apply all pentest remediations — 36 findings across 36 files
CRITICAL fixes:
- Race condition (TOCTOU) in payout/refund with SELECT FOR UPDATE (CRITICAL-001/002)
- IDOR on analytics endpoint — ownership check enforced (CRITICAL-003)
- CSWSH on all WebSocket endpoints — origin whitelist (CRITICAL-004)
- Mass assignment on user self-update — strip privileged fields (CRITICAL-005)

HIGH fixes:
- Path traversal in marketplace upload — UUID filenames (HIGH-001)
- IP spoofing — use Gin trusted proxy c.ClientIP() (HIGH-002)
- Popularity metrics (followers, likes) set to json:"-" (HIGH-003)
- bcrypt cost hardened to 12 everywhere (HIGH-004)
- Refresh token lock made mandatory (HIGH-005)
- Stream token replay prevention with access_count (HIGH-006)
- Subscription trial race condition fixed (HIGH-007)
- License download expiration check (HIGH-008)
- Webhook amount validation (HIGH-009)
- pprof endpoint removed from production (HIGH-010)

MEDIUM fixes:
- WebSocket message size limit 64KB (MEDIUM-010)
- HSTS header in nginx production (MEDIUM-001)
- CORS origin restricted in nginx-rtmp (MEDIUM-002)
- Docker alpine pinned to 3.21 (MEDIUM-003/004)
- Redis authentication enforced (MEDIUM-005)
- GDPR account deletion expanded (MEDIUM-006)
- .gitignore hardened (MEDIUM-007)

LOW/INFO fixes:
- GitHub Actions SHA pinning on all workflows (LOW-001)
- .env.example security documentation (INFO-001)
- Production CORS set to HTTPS (LOW-002)

All tests pass. Go and Rust compile clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:44:46 +01:00

191 lines
5.6 KiB
Go

package analytics
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"veza-backend-api/internal/services"
"veza-backend-api/internal/types"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// MockAnalyticsService implements AnalyticsServiceInterface for testing
type MockAnalyticsService struct {
mock.Mock
}
func (m *MockAnalyticsService) GetTrackStats(ctx context.Context, trackID uuid.UUID) (*types.TrackStats, error) {
args := m.Called(ctx, trackID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*types.TrackStats), args.Error(1)
}
func (m *MockAnalyticsService) GetPlaysOverTime(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, interval string) ([]services.PlayTimePoint, error) {
args := m.Called(ctx, trackID, startDate, endDate, interval)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]services.PlayTimePoint), args.Error(1)
}
func (m *MockAnalyticsService) GetDB() *gorm.DB {
args := m.Called()
return args.Get(0).(*gorm.DB)
}
// setupTestDB creates an in-memory SQLite DB with tracks table for ownership checks
func setupTestDB(trackID, creatorID uuid.UUID) *gorm.DB {
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
db.Exec("CREATE TABLE tracks (id TEXT PRIMARY KEY, creator_id TEXT)")
db.Exec("INSERT INTO tracks (id, creator_id) VALUES (?, ?)", trackID.String(), creatorID.String())
return db
}
// MockAnalyticsJobWorker mocks JobWorker for analytics
type MockAnalyticsJobWorker struct {
mock.Mock
}
func (m *MockAnalyticsJobWorker) EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{}) {
m.Called(eventName, userID, payload)
}
func setupTestRouter(mockService *MockAnalyticsService, mockJobWorker *MockAnalyticsJobWorker) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
logger := zap.NewNop()
handler := NewHandlerWithInterface(mockService, logger)
if mockJobWorker != nil {
handler.SetJobWorker(mockJobWorker)
}
api := router.Group("/api/v1/analytics")
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.GET("", handler.GetAnalytics)
api.GET("/tracks/:id", handler.GetTrackAnalyticsDashboard)
api.POST("/events", handler.RecordEvent)
}
return router
}
func TestHandler_RecordEvent_Success(t *testing.T) {
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestRouter(mockService, mockJobWorker)
userID := uuid.New()
reqBody := RecordEventRequest{
EventName: "track_liked",
Payload: map[string]interface{}{"track_id": uuid.New().String()},
}
mockJobWorker.On("EnqueueAnalyticsJob", "track_liked", &userID, reqBody.Payload).Return()
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", "/api/v1/analytics/events", 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.Equal(t, http.StatusOK, w.Code)
mockJobWorker.AssertExpectations(t)
}
func TestHandler_RecordEvent_NoJobWorker(t *testing.T) {
mockService := new(MockAnalyticsService)
router := setupTestRouter(mockService, nil)
userID := uuid.New()
reqBody := RecordEventRequest{
EventName: "track_liked",
Payload: map[string]interface{}{"track_id": uuid.New().String()},
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", "/api/v1/analytics/events", 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.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestHandler_GetTrackAnalyticsDashboard_Success(t *testing.T) {
mockService := new(MockAnalyticsService)
mockJobWorker := new(MockAnalyticsJobWorker)
router := setupTestRouter(mockService, mockJobWorker)
trackID := uuid.New()
creatorID := uuid.New()
expectedStats := &types.TrackStats{
TotalPlays: 100,
UniqueListeners: 50,
AverageDuration: 120,
CompletionRate: 0.8,
}
expectedPoints := []services.PlayTimePoint{
{Date: time.Now(), Count: 10},
}
testDB := setupTestDB(trackID, creatorID)
mockService.On("GetDB").Return(testDB)
mockService.On("GetTrackStats", mock.Anything, trackID).Return(expectedStats, nil)
mockService.On("GetPlaysOverTime", mock.Anything, trackID, mock.Anything, mock.Anything, "day").Return(expectedPoints, nil)
req, _ := http.NewRequest("GET", "/api/v1/analytics/tracks/"+trackID.String(), nil)
req.Header.Set("X-User-ID", creatorID.String()) // SECURITY: Must be track creator
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
mockService.AssertExpectations(t)
}
func TestHandler_GetTrackAnalyticsDashboard_IDOR_Blocked(t *testing.T) {
mockService := new(MockAnalyticsService)
router := setupTestRouter(mockService, nil)
trackID := uuid.New()
creatorID := uuid.New()
attackerID := uuid.New()
testDB := setupTestDB(trackID, creatorID)
mockService.On("GetDB").Return(testDB)
req, _ := http.NewRequest("GET", "/api/v1/analytics/tracks/"+trackID.String(), nil)
req.Header.Set("X-User-ID", attackerID.String()) // Different user — should be blocked
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code) // IDOR blocked
}