package services import ( "database/sql" "io" "net/http" "regexp" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" "veza-backend-api/internal/database" "veza-backend-api/internal/repositories" ) // setupOAuthServiceForTests creates OAuthService with minimal deps for tests that don't call HandleCallback. // Uses sqlite in-memory when db has no GormDB (e.g. from sqlmock). func setupOAuthServiceForTests(t *testing.T, db *database.Database) *OAuthService { t.Helper() jwtService, err := NewJWTService("", "", "test-secret-key-minimum-32-characters-long", "veza-api", "veza-app") require.NoError(t, err) // Use db for SessionService (needs sql.DB) sessionService := NewSessionService(db, zap.NewNop()) // UserService needs GormDB - use db.GormDB if available, else nil (tests don't call HandleCallback) var userService *UserService if db.GormDB != nil { userRepo := repositories.NewGormUserRepository(db.GormDB) userService = NewUserServiceWithDB(userRepo, db.GormDB) } return NewOAuthService(db, zap.NewNop(), jwtService, sessionService, userService, nil) } // Helper to setup mock DB func setupMockDB(t *testing.T) (*database.Database, sqlmock.Sqlmock) { db, mock, err := sqlmock.New() require.NoError(t, err) dbWrapper := &database.Database{ DB: db, Logger: zap.NewNop(), } return dbWrapper, mock } func TestOAuthService_GenerateStateToken_Success(t *testing.T) { // Setup db, mock := setupMockDB(t) defer db.DB.Close() logger := zap.NewNop() service := &OAuthService{ db: db, logger: logger, } provider := "google" redirectURL := "http://example.com" // Expectation: state_token, provider, redirect_url, code_verifier, expires_at mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO oauth_states`)). WithArgs(sqlmock.AnyArg(), provider, redirectURL, sqlmock.AnyArg(), sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) // Execute stateToken, codeVerifier, err := service.GenerateStateToken(provider, redirectURL) // Assert assert.NoError(t, err) assert.NotEmpty(t, stateToken) assert.NotEmpty(t, codeVerifier) assert.NoError(t, mock.ExpectationsWereMet()) } func TestOAuthService_ValidateStateToken_Success(t *testing.T) { // Setup db, mock := setupMockDB(t) defer db.DB.Close() logger := zap.NewNop() service := &OAuthService{ db: db, logger: logger, } token := "valid_token" now := time.Now() // Expectation: include code_verifier for PKCE rows := sqlmock.NewRows([]string{"id", "state_token", "provider", "redirect_url", "code_verifier", "expires_at", "created_at"}). AddRow(1, token, "google", "http://example.com", "pkce_verifier_123", now.Add(time.Hour), now) mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, state_token, provider, redirect_url, code_verifier, expires_at, created_at FROM oauth_states WHERE state_token = $1`)). WithArgs(token). WillReturnRows(rows) mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM oauth_states WHERE id = $1`)). WithArgs(1). WillReturnResult(sqlmock.NewResult(1, 1)) // Execute state, err := service.ValidateStateToken(token) // Assert assert.NoError(t, err) assert.NotNil(t, state) assert.Equal(t, token, state.StateToken) assert.Equal(t, "pkce_verifier_123", state.CodeVerifier) assert.NoError(t, mock.ExpectationsWereMet()) } func TestOAuthService_ValidateStateToken_NotFound(t *testing.T) { // Setup db, mock := setupMockDB(t) defer db.DB.Close() logger := zap.NewNop() service := &OAuthService{ db: db, logger: logger, } token := "invalid_token" // Expectation mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, state_token, provider, redirect_url, code_verifier, expires_at, created_at FROM oauth_states WHERE state_token = $1`)). WithArgs(token). WillReturnError(sql.ErrNoRows) // Execute state, err := service.ValidateStateToken(token) // Assert assert.Error(t, err) assert.Equal(t, "invalid state token", err.Error()) assert.Nil(t, state) assert.NoError(t, mock.ExpectationsWereMet()) } func TestOAuthService_ValidateStateToken_Expired(t *testing.T) { // Setup db, mock := setupMockDB(t) defer db.DB.Close() logger := zap.NewNop() service := &OAuthService{ db: db, logger: logger, } token := "expired_token" now := time.Now() // Expectation rows := sqlmock.NewRows([]string{"id", "state_token", "provider", "redirect_url", "code_verifier", "expires_at", "created_at"}). AddRow(1, token, "google", "http://example.com", "verifier", now.Add(-time.Hour), now.Add(-2*time.Hour)) mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, state_token, provider, redirect_url, code_verifier, expires_at, created_at FROM oauth_states WHERE state_token = $1`)). WithArgs(token). WillReturnRows(rows) // Execute state, err := service.ValidateStateToken(token) // Assert assert.Error(t, err) assert.Equal(t, "state token expired", err.Error()) assert.Nil(t, state) assert.NoError(t, mock.ExpectationsWereMet()) } func TestOAuthService_GetAuthURL_Discord(t *testing.T) { db, mock := setupMockDB(t) defer db.DB.Close() svc := setupOAuthServiceForTests(t, db) svc.InitializeConfigs("", "", "", "", "discord-client", "discord-secret", "", "", "http://localhost:8080") mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO oauth_states`)). WithArgs(sqlmock.AnyArg(), "discord", "", sqlmock.AnyArg(), sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) url, err := svc.GetAuthURL("discord") assert.NoError(t, err) assert.NotEmpty(t, url) assert.Contains(t, url, "discord.com/api/oauth2/authorize") assert.Contains(t, url, "identify") assert.Contains(t, url, "email") assert.Contains(t, url, "discord-client") assert.Contains(t, url, "code_challenge") assert.NoError(t, mock.ExpectationsWereMet()) } func TestOAuthService_GetAuthURL_Spotify(t *testing.T) { db, mock := setupMockDB(t) defer db.DB.Close() svc := setupOAuthServiceForTests(t, db) svc.InitializeConfigs("", "", "", "", "", "", "spotify-client", "spotify-secret", "http://localhost:8080") mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO oauth_states`)). WithArgs(sqlmock.AnyArg(), "spotify", "", sqlmock.AnyArg(), sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) url, err := svc.GetAuthURL("spotify") assert.NoError(t, err) assert.NotEmpty(t, url) assert.Contains(t, url, "accounts.spotify.com/authorize") assert.Contains(t, url, "user-read-email") assert.Contains(t, url, "user-read-private") assert.Contains(t, url, "spotify-client") assert.Contains(t, url, "code_challenge") assert.Contains(t, url, "code_challenge_method=S256") assert.NoError(t, mock.ExpectationsWereMet()) } func TestOAuthService_GetAvailableProviders(t *testing.T) { db, _ := setupMockDB(t) defer db.DB.Close() svc := setupOAuthServiceForTests(t, db) providers := svc.GetAvailableProviders() assert.Empty(t, providers) svc.InitializeConfigs("", "", "gid", "gsec", "did", "dsec", "sid", "ssec", "http://localhost") // github, discord, spotify providers = svc.GetAvailableProviders() assert.Contains(t, providers, "github") assert.Contains(t, providers, "discord") assert.Contains(t, providers, "spotify") assert.Len(t, providers, 3) } // mockOAuthTransport mocks HTTP responses for Discord/Spotify user info APIs type mockOAuthTransport struct { discordResponse string spotifyResponse string } func (m *mockOAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { url := req.URL.String() body := "" if strings.Contains(url, "discord.com") { body = m.discordResponse } else if strings.Contains(url, "spotify.com") { body = m.spotifyResponse } return &http.Response{ StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), }, nil } func TestOAuthService_GetUserInfo_Discord(t *testing.T) { discordJSON := `{"id":"123456","username":"testuser","email":"test@example.com","avatar":"abc123"}` transport := &mockOAuthTransport{discordResponse: discordJSON} client := &http.Client{Transport: transport, Timeout: 5 * time.Second} db, mock := setupMockDB(t) defer db.DB.Close() svc := setupOAuthServiceForTests(t, db) svc.InitializeConfigs("", "", "", "", "did", "dsec", "", "", "http://localhost") svc.circuitBreaker = NewCircuitBreakerHTTPClient(client, "oauth-test", zap.NewNop()) user, err := svc.getUserInfo("discord", "fake-token") require.NoError(t, err) assert.Equal(t, "123456", user.ProviderID) assert.Equal(t, "testuser", user.Username) assert.Equal(t, "test@example.com", user.Email) assert.Equal(t, "testuser", user.Name) assert.Equal(t, "abc123", user.Avatar) _ = mock } func TestOAuthService_GetUserInfo_Spotify(t *testing.T) { spotifyJSON := `{"id":"spot123","display_name":"Spot User","email":"spot@example.com","images":[{"url":"https://avatar.url"}]}` transport := &mockOAuthTransport{spotifyResponse: spotifyJSON} client := &http.Client{Transport: transport, Timeout: 5 * time.Second} db, _ := setupMockDB(t) defer db.DB.Close() svc := setupOAuthServiceForTests(t, db) svc.InitializeConfigs("", "", "", "", "", "", "sid", "ssec", "http://localhost") svc.circuitBreaker = NewCircuitBreakerHTTPClient(client, "oauth-test", zap.NewNop()) user, err := svc.getUserInfo("spotify", "fake-token") require.NoError(t, err) assert.Equal(t, "spot123", user.ProviderID) assert.Equal(t, "Spot User", user.Username) assert.Equal(t, "spot@example.com", user.Email) assert.Equal(t, "https://avatar.url", user.Avatar) } func TestOAuthService_ValidateRedirectURL_EvilDomain(t *testing.T) { // v0.902: redirect to evil.com should be rejected svc := NewOAuthService(nil, zap.NewNop(), nil, nil, nil, &OAuthServiceConfig{ AllowedDomains: []string{"https://app.veza.com", "https://veza.fr:5173"}, }) err := svc.validateRedirectURL("https://evil.com/auth/callback") assert.Error(t, err) assert.Contains(t, err.Error(), "not allowed") } func TestOAuthService_GetUserInfo_Spotify_FallbackEmail(t *testing.T) { // Spotify without email - should fallback to id@spotify.user spotifyJSON := `{"id":"spot456","display_name":"","images":[]}` transport := &mockOAuthTransport{spotifyResponse: spotifyJSON} client := &http.Client{Transport: transport, Timeout: 5 * time.Second} db, _ := setupMockDB(t) defer db.DB.Close() svc := setupOAuthServiceForTests(t, db) svc.InitializeConfigs("", "", "", "", "", "", "sid", "ssec", "http://localhost") svc.circuitBreaker = NewCircuitBreakerHTTPClient(client, "oauth-test", zap.NewNop()) user, err := svc.getUserInfo("spotify", "fake-token") require.NoError(t, err) assert.Equal(t, "spot456", user.ProviderID) assert.Equal(t, "spot456", user.Username) // fallback to ID when display_name empty assert.Equal(t, "spot456@spotify.user", user.Email) }