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>
498 lines
12 KiB
Go
498 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"mime/multipart"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
|
|
"veza-backend-api/internal/models"
|
|
"veza-backend-api/internal/types"
|
|
)
|
|
|
|
// ========== MOCK REPOSITORY ==========
|
|
|
|
type MockUserRepository struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *MockUserRepository) GetByID(_ context.Context, id string) (*models.User, error) {
|
|
args := m.Called(id)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(*models.User), args.Error(1)
|
|
}
|
|
|
|
func (m *MockUserRepository) GetByEmail(_ context.Context, email string) (*models.User, error) {
|
|
args := m.Called(email)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(*models.User), args.Error(1)
|
|
}
|
|
|
|
func (m *MockUserRepository) GetByUsername(_ context.Context, username string) (*models.User, error) {
|
|
args := m.Called(username)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(*models.User), args.Error(1)
|
|
}
|
|
|
|
func (m *MockUserRepository) Create(_ context.Context, user *models.User) error {
|
|
args := m.Called(user)
|
|
return args.Error(0)
|
|
}
|
|
|
|
func (m *MockUserRepository) Update(_ context.Context, user *models.User) error {
|
|
args := m.Called(user)
|
|
return args.Error(0)
|
|
}
|
|
|
|
func (m *MockUserRepository) Delete(_ context.Context, id string) error {
|
|
args := m.Called(id)
|
|
return args.Error(0)
|
|
}
|
|
|
|
// ========== TEST DATABASE SETUP ==========
|
|
|
|
func setupUserTestDB(t *testing.T) *gorm.DB {
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to connect to test database: %v", err)
|
|
}
|
|
|
|
// Auto-migrate les modèles nécessaires
|
|
err = db.AutoMigrate(
|
|
&models.User{},
|
|
&models.UserSettings{},
|
|
&models.UserProfile{},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Failed to migrate: %v", err)
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
// ========== TESTS ==========
|
|
|
|
func TestUserService_GetProfile_Success(t *testing.T) {
|
|
// Setup
|
|
mockRepo := new(MockUserRepository)
|
|
service := NewUserService(mockRepo)
|
|
|
|
userID := uuid.New()
|
|
user := &models.User{
|
|
ID: userID,
|
|
Username: "testuser",
|
|
FirstName: "Test",
|
|
LastName: "User",
|
|
Email: "test@example.com",
|
|
CreatedAt: time.Now(),
|
|
IsPublic: true,
|
|
}
|
|
|
|
mockRepo.On("GetByID", userID.String()).Return(user, nil)
|
|
|
|
// Execute
|
|
profile, err := service.GetProfile(context.Background(), userID, &userID)
|
|
|
|
// Assert
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, profile)
|
|
assert.Equal(t, userID, profile.ID)
|
|
assert.Equal(t, "testuser", profile.Username)
|
|
mockRepo.AssertExpectations(t)
|
|
}
|
|
|
|
func TestUserService_GetProfile_NotFound(t *testing.T) {
|
|
// Setup
|
|
mockRepo := new(MockUserRepository)
|
|
service := NewUserService(mockRepo)
|
|
|
|
userID := uuid.New()
|
|
|
|
mockRepo.On("GetByID", userID.String()).Return(nil, errors.New("not found"))
|
|
|
|
// Execute
|
|
profile, err := service.GetProfile(context.Background(), userID, &userID)
|
|
|
|
// Assert
|
|
assert.Error(t, err)
|
|
assert.Nil(t, profile)
|
|
}
|
|
|
|
func TestUserService_GetProfile_Private(t *testing.T) {
|
|
// Setup
|
|
mockRepo := new(MockUserRepository)
|
|
service := NewUserService(mockRepo)
|
|
|
|
userID := uuid.New()
|
|
otherID := uuid.New()
|
|
bio := "Secret bio"
|
|
user := &models.User{
|
|
ID: userID,
|
|
Username: "privateuser",
|
|
IsPublic: false,
|
|
Bio: bio,
|
|
}
|
|
|
|
mockRepo.On("GetByID", userID.String()).Return(user, nil)
|
|
|
|
// Execute as another user
|
|
profile, err := service.GetProfile(context.Background(), userID, &otherID)
|
|
|
|
// Assert
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, profile)
|
|
assert.Nil(t, profile.Bio, "Bio should be nil for private profile viewed by other")
|
|
}
|
|
|
|
func TestUserService_GetProfileByUsername_Success(t *testing.T) {
|
|
// Setup
|
|
mockRepo := new(MockUserRepository)
|
|
service := NewUserService(mockRepo)
|
|
|
|
userID := uuid.New()
|
|
username := "testuser"
|
|
user := &models.User{
|
|
ID: userID,
|
|
Username: username,
|
|
IsPublic: true,
|
|
}
|
|
|
|
mockRepo.On("GetByUsername", username).Return(user, nil)
|
|
mockRepo.On("GetByID", userID.String()).Return(user, nil)
|
|
|
|
// Execute
|
|
profile, err := service.GetProfileByUsername(context.Background(), username, &userID)
|
|
|
|
// Assert
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, username, profile.Username)
|
|
}
|
|
|
|
func TestUserService_UpdateProfile_Success(t *testing.T) {
|
|
// Setup
|
|
mockRepo := new(MockUserRepository)
|
|
service := NewUserService(mockRepo)
|
|
|
|
userID := uuid.New()
|
|
user := &models.User{
|
|
ID: userID,
|
|
Username: "oldname",
|
|
Bio: "old bio",
|
|
}
|
|
|
|
newName := "newname"
|
|
newBio := "new bio"
|
|
req := types.UpdateProfileRequest{
|
|
Username: &newName,
|
|
Bio: &newBio,
|
|
}
|
|
|
|
mockRepo.On("GetByID", userID.String()).Return(user, nil)
|
|
mockRepo.On("Update", mock.MatchedBy(func(u *models.User) bool {
|
|
return u.Username == "newname" && u.Bio == "new bio"
|
|
})).Return(nil)
|
|
|
|
// Execute
|
|
profile, err := service.UpdateProfile(context.Background(), userID, req)
|
|
|
|
// Assert
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "newname", profile.Username)
|
|
assert.Equal(t, "new bio", *profile.Bio)
|
|
}
|
|
|
|
func TestUserService_UpdateProfile_WithSocialLinks_Success(t *testing.T) {
|
|
// Setup
|
|
mockRepo := new(MockUserRepository)
|
|
service := NewUserService(mockRepo)
|
|
|
|
userID := uuid.New()
|
|
user := &models.User{
|
|
ID: userID,
|
|
}
|
|
|
|
socialLinks := map[string]interface{}{
|
|
"twitter": "https://twitter.com/test",
|
|
}
|
|
req := types.UpdateProfileRequest{
|
|
SocialLinks: socialLinks,
|
|
}
|
|
|
|
mockRepo.On("GetByID", userID.String()).Return(user, nil)
|
|
mockRepo.On("Update", mock.MatchedBy(func(u *models.User) bool {
|
|
return u.SocialLinks != "" && strings.Contains(u.SocialLinks, "twitter")
|
|
})).Return(nil)
|
|
|
|
// Execute
|
|
profile, err := service.UpdateProfile(context.Background(), userID, req)
|
|
|
|
// Assert
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, profile.SocialLinks)
|
|
assert.Equal(t, "https://twitter.com/test", profile.SocialLinks["twitter"])
|
|
mockRepo.AssertExpectations(t)
|
|
}
|
|
|
|
func TestUserService_GetUserSettings_Success(t *testing.T) {
|
|
// Setup with DB
|
|
db := setupUserTestDB(t)
|
|
mockRepo := new(MockUserRepository)
|
|
service := NewUserServiceWithDB(mockRepo, db)
|
|
|
|
userID := uuid.New()
|
|
|
|
// Create settings in DB
|
|
settings := models.UserSettings{
|
|
UserID: userID,
|
|
EmailNotifications: true,
|
|
}
|
|
db.Create(&settings)
|
|
|
|
// Create profile in DB
|
|
profile := models.UserProfile{
|
|
UserID: userID,
|
|
Language: "fr",
|
|
Timezone: "Europe/Paris",
|
|
}
|
|
db.Create(&profile)
|
|
|
|
// Execute
|
|
resp, err := service.GetUserSettings(userID)
|
|
|
|
// Assert
|
|
assert.NoError(t, err)
|
|
assert.True(t, resp.Notifications.Email)
|
|
assert.Equal(t, "fr", resp.Preferences.Language)
|
|
assert.Equal(t, "Europe/Paris", resp.Preferences.Timezone)
|
|
}
|
|
|
|
func TestUserService_UpdateUserSettings_Success(t *testing.T) {
|
|
// Setup with DB
|
|
db := setupUserTestDB(t)
|
|
mockRepo := new(MockUserRepository)
|
|
service := NewUserServiceWithDB(mockRepo, db)
|
|
|
|
userID := uuid.New()
|
|
|
|
// Initial state
|
|
settings := models.UserSettings{UserID: userID, EmailNotifications: false}
|
|
db.Create(&settings)
|
|
profile := models.UserProfile{UserID: userID, Language: "en"}
|
|
db.Create(&profile)
|
|
|
|
// Request
|
|
newLang := "es"
|
|
req := types.UpdateSettingsRequest{
|
|
Notifications: &types.NotificationSettings{Email: true},
|
|
Preferences: &types.PreferenceSettings{Language: newLang},
|
|
}
|
|
|
|
// Execute
|
|
err := service.UpdateUserSettings(userID, &req)
|
|
|
|
// Assert
|
|
assert.NoError(t, err)
|
|
|
|
// Verify DB
|
|
var dbSettings models.UserSettings
|
|
db.First(&dbSettings, "user_id = ?", userID)
|
|
assert.True(t, dbSettings.EmailNotifications)
|
|
|
|
var dbProfile models.UserProfile
|
|
db.First(&dbProfile, "user_id = ?", userID)
|
|
assert.Equal(t, "es", dbProfile.Language)
|
|
}
|
|
|
|
func TestUserService_DeleteUser_Success(t *testing.T) {
|
|
// Setup
|
|
db := setupUserTestDB(t)
|
|
mockRepo := new(MockUserRepository)
|
|
service := NewUserServiceWithDB(mockRepo, db)
|
|
|
|
userID := uuid.New()
|
|
user := &models.User{ID: userID, IsActive: true}
|
|
|
|
// Create user in DB for soft delete check (if needed by service implementation detail)
|
|
db.Create(user)
|
|
|
|
mockRepo.On("GetByID", userID.String()).Return(user, nil)
|
|
mockRepo.On("Delete", userID.String()).Return(nil)
|
|
|
|
// Execute
|
|
ctx := context.Background()
|
|
err := service.DeleteUser(ctx, userID)
|
|
|
|
// Assert
|
|
assert.NoError(t, err)
|
|
|
|
// Verify IsActive updated in DB (as per implementation)
|
|
var dbUser models.User
|
|
db.First(&dbUser, "id = ?", userID)
|
|
assert.False(t, dbUser.IsActive)
|
|
|
|
mockRepo.AssertExpectations(t)
|
|
}
|
|
|
|
func TestUserService_UploadAvatar(t *testing.T) {
|
|
// Setup
|
|
mockRepo := new(MockUserRepository)
|
|
service := NewUserService(mockRepo)
|
|
|
|
// Use temp dir for uploads
|
|
tmpDir, err := os.MkdirTemp("", "avatar_test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
service.SetUploadDir(tmpDir)
|
|
|
|
userID := uuid.New()
|
|
content := []byte("fake image content")
|
|
fileHeader := createMultipartFileHeader(t, "avatar.png", content, "image/png")
|
|
|
|
// Execute
|
|
url, err := service.UploadAvatar(userID, fileHeader)
|
|
|
|
// Assert
|
|
assert.NoError(t, err)
|
|
assert.Contains(t, url, "/uploads/avatars/")
|
|
assert.Contains(t, url, ".png")
|
|
}
|
|
|
|
func TestUserService_UpdateAvatarURL(t *testing.T) {
|
|
// Setup
|
|
mockRepo := new(MockUserRepository)
|
|
service := NewUserService(mockRepo)
|
|
|
|
userID := uuid.New()
|
|
user := &models.User{ID: userID}
|
|
newAvatar := "/uploads/avatars/new.png"
|
|
|
|
mockRepo.On("GetByID", userID.String()).Return(user, nil)
|
|
mockRepo.On("Update", mock.MatchedBy(func(u *models.User) bool {
|
|
return u.Avatar == newAvatar
|
|
})).Return(nil)
|
|
|
|
// Execute
|
|
err := service.UpdateAvatarURL(context.Background(), userID, newAvatar)
|
|
|
|
// Assert
|
|
assert.NoError(t, err)
|
|
mockRepo.AssertExpectations(t)
|
|
}
|
|
|
|
func TestUserService_ValidateUsername(t *testing.T) {
|
|
// Setup
|
|
mockRepo := new(MockUserRepository)
|
|
service := NewUserService(mockRepo)
|
|
|
|
userID := uuid.New()
|
|
otherID := uuid.New()
|
|
currentUsername := "current"
|
|
newUsername := "newname"
|
|
takenUsername := "taken"
|
|
|
|
user := &models.User{
|
|
ID: userID,
|
|
Username: currentUsername,
|
|
}
|
|
|
|
mockRepo.On("GetByID", userID.String()).Return(user, nil)
|
|
// Case 1: Username available
|
|
mockRepo.On("GetByUsername", newUsername).Return(nil, gorm.ErrRecordNotFound)
|
|
|
|
// Case 2: Username taken
|
|
otherUser := &models.User{ID: otherID, Username: takenUsername}
|
|
mockRepo.On("GetByUsername", takenUsername).Return(otherUser, nil)
|
|
|
|
// Execute Case 1
|
|
err := service.ValidateUsername(context.Background(), userID, newUsername)
|
|
assert.NoError(t, err)
|
|
|
|
// Execute Case 2
|
|
err = service.ValidateUsername(context.Background(), userID, takenUsername)
|
|
assert.Error(t, err)
|
|
assert.Equal(t, "username already taken", err.Error())
|
|
|
|
// Case 3: Rate limit
|
|
// (Skipping rate limit test complexity for now as logic is standard time check)
|
|
}
|
|
|
|
func TestUserService_CalculateProfileCompletion(t *testing.T) {
|
|
// Setup
|
|
mockRepo := new(MockUserRepository)
|
|
service := NewUserService(mockRepo)
|
|
|
|
userID := uuid.New()
|
|
avatar := "avatar.png"
|
|
bio := "bio"
|
|
socialLinks := `{"twitter": "https://twitter.com/test"}`
|
|
user := &models.User{
|
|
ID: userID,
|
|
Username: "complete",
|
|
FirstName: "John",
|
|
LastName: "Doe",
|
|
Bio: bio,
|
|
Avatar: avatar,
|
|
IsPublic: true,
|
|
SocialLinks: socialLinks,
|
|
}
|
|
|
|
mockRepo.On("GetByID", userID.String()).Return(user, nil)
|
|
|
|
// Execute
|
|
completion, err := service.CalculateProfileCompletion(context.Background(), userID)
|
|
|
|
// Assert
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 100, completion.Percentage)
|
|
assert.Empty(t, completion.Missing)
|
|
}
|
|
|
|
// Helper
|
|
func createMultipartFileHeader(t *testing.T, filename string, content []byte, contentType string) *multipart.FileHeader {
|
|
body := &bytes.Buffer{}
|
|
writer := multipart.NewWriter(body)
|
|
part, err := writer.CreateFormFile("file", filename)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = part.Write(content)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = writer.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
reader := multipart.NewReader(body, writer.Boundary())
|
|
form, err := reader.ReadForm(1024 * 1024)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
headers := form.File["file"]
|
|
if len(headers) == 0 {
|
|
t.Fatal("no file header")
|
|
}
|
|
headers[0].Header.Set("Content-Type", contentType)
|
|
|
|
return headers[0]
|
|
}
|