From 4720bb20b21fd9977b06d3417de7365eb97dd40d Mon Sep 17 00:00:00 2001 From: senke Date: Fri, 27 Feb 2026 09:58:53 +0100 Subject: [PATCH] feat(auth): v0.911 Keystone - OAuth and auth integration tests - Add access token blacklist on logout (VEZA-SEC-006) - Extend OAuthService for mock provider injection in tests - Add oauth_google_test.go: full OAuth Google flow with mocked provider - Add oauth_github_test.go: OAuth GitHub flow with PKCE verification - Add token_refresh_test.go: E2E refresh via httpOnly cookies - Add logout_blacklist_test.go: E2E logout + token blacklist - Fix testutils import path in resume_upload_test, track_quota_test - Fix CreatorID -> UserID in track_quota_test - Add test:integration script to package.json Release: v0.911 Keystone --- VERSION | 2 +- veza-backend-api/internal/handlers/auth.go | 17 ++ .../internal/services/oauth_service.go | 62 +++-- veza-backend-api/package.json | 3 +- .../integration/logout_blacklist_test.go | 250 ++++++++++++++++++ .../tests/integration/oauth_github_test.go | 186 +++++++++++++ .../tests/integration/oauth_google_test.go | 179 +++++++++++++ .../tests/integration/resume_upload_test.go | 2 +- .../tests/integration/token_refresh_test.go | 233 ++++++++++++++++ .../tests/integration/track_quota_test.go | 6 +- 10 files changed, 917 insertions(+), 23 deletions(-) create mode 100644 veza-backend-api/tests/integration/logout_blacklist_test.go create mode 100644 veza-backend-api/tests/integration/oauth_github_test.go create mode 100644 veza-backend-api/tests/integration/oauth_google_test.go create mode 100644 veza-backend-api/tests/integration/token_refresh_test.go diff --git a/VERSION b/VERSION index db0cd10a3..a7403c6bf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.903 +0.911 diff --git a/veza-backend-api/internal/handlers/auth.go b/veza-backend-api/internal/handlers/auth.go index 28eaea73d..9773fd37d 100644 --- a/veza-backend-api/internal/handlers/auth.go +++ b/veza-backend-api/internal/handlers/auth.go @@ -632,6 +632,23 @@ func Logout(authService *auth.AuthService, sessionService *services.SessionServi } } + // VEZA-SEC-006: Add access token to blacklist so it is rejected immediately + if cfg.TokenBlacklist != nil { + authHeader := c.GetHeader("Authorization") + if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { + accessToken := strings.TrimPrefix(authHeader, "Bearer ") + ttl := 5 * time.Minute // default if we cannot parse + if claims, err := authService.JWTService.ValidateToken(accessToken); err == nil && claims.ExpiresAt != nil { + if remaining := time.Until(claims.ExpiresAt.Time); remaining > 0 { + ttl = remaining + } + } + if err := cfg.TokenBlacklist.Add(c.Request.Context(), accessToken, ttl); err != nil { + // Log but don't fail - logout should succeed even if blacklist fails + } + } + } + // SECURITY: Supprimer le cookie refresh_token lors du logout refreshTokenCookie := &http.Cookie{ Name: "refresh_token", diff --git a/veza-backend-api/internal/services/oauth_service.go b/veza-backend-api/internal/services/oauth_service.go index 15539fe5c..8268ba601 100644 --- a/veza-backend-api/internal/services/oauth_service.go +++ b/veza-backend-api/internal/services/oauth_service.go @@ -38,6 +38,8 @@ type OAuthService struct { cryptoService *CryptoService // v0.902: encrypt OAuth provider tokens at rest (nil = store plaintext) frontendURL string // v0.902: default redirect URL allowedDomains []string // v0.902: whitelist for OAuth redirect (OAUTH_ALLOWED_REDIRECT_DOMAINS) + testTokenURL string // v0.911: override for integration tests (mock provider) + testUserInfoURL string // v0.911: override for integration tests (mock userinfo) } // OAuthAccount represents an OAuth account linking @@ -69,16 +71,23 @@ type OAuthState struct { } // OAuthServiceConfig holds optional config for OAuth (v0.902) +// v0.911: TestTokenURL, TestUserInfoURL, TestHTTPClient for integration tests (mock provider) type OAuthServiceConfig struct { CryptoService *CryptoService FrontendURL string AllowedDomains []string + TestTokenURL string // Override token exchange URL (for tests) + TestUserInfoURL string // Override userinfo API URL (for tests) + TestHTTPClient *http.Client // Override HTTP client (for tests) } // NewOAuthService creates a new OAuth service -// cfg: optional config for crypto, redirect validation (v0.902) +// cfg: optional config for crypto, redirect validation (v0.902), test overrides (v0.911) func NewOAuthService(db *database.Database, logger *zap.Logger, jwtService *JWTService, sessionService *SessionService, userService *UserService, cfg *OAuthServiceConfig) *OAuthService { httpClient := &http.Client{Timeout: 10 * time.Second} + if cfg != nil && cfg.TestHTTPClient != nil { + httpClient = cfg.TestHTTPClient + } svc := &OAuthService{ db: db, logger: logger, @@ -91,14 +100,25 @@ func NewOAuthService(db *database.Database, logger *zap.Logger, jwtService *JWTS svc.cryptoService = cfg.CryptoService svc.frontendURL = cfg.FrontendURL svc.allowedDomains = cfg.AllowedDomains + svc.testTokenURL = cfg.TestTokenURL + svc.testUserInfoURL = cfg.TestUserInfoURL } return svc } // InitializeConfigs initializes OAuth configurations func (os *OAuthService) InitializeConfigs(googleClientID, googleClientSecret, githubClientID, githubClientSecret, discordClientID, discordClientSecret, spotifyClientID, spotifyClientSecret, baseURL string) { + testEndpoint := oauth2.Endpoint{} + if os.testTokenURL != "" { + testEndpoint = oauth2.Endpoint{TokenURL: os.testTokenURL, AuthURL: os.testTokenURL} + } + // Google OAuth if googleClientID != "" && googleClientSecret != "" { + endpoint := google.Endpoint + if os.testTokenURL != "" { + endpoint = testEndpoint + } os.googleConfig = &oauth2.Config{ ClientID: googleClientID, ClientSecret: googleClientSecret, @@ -107,21 +127,25 @@ func (os *OAuthService) InitializeConfigs(googleClientID, googleClientSecret, gi "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", }, - Endpoint: google.Endpoint, + Endpoint: endpoint, } } // GitHub OAuth if githubClientID != "" && githubClientSecret != "" { + endpoint := oauth2.Endpoint{ + AuthURL: "https://github.com/login/oauth/authorize", + TokenURL: "https://github.com/login/oauth/access_token", + } + if os.testTokenURL != "" { + endpoint = testEndpoint + } os.githubConfig = &oauth2.Config{ ClientID: githubClientID, ClientSecret: githubClientSecret, RedirectURL: fmt.Sprintf("%s/api/v1/auth/oauth/github/callback", baseURL), Scopes: []string{"user:email", "read:user"}, - Endpoint: oauth2.Endpoint{ - AuthURL: "https://github.com/login/oauth/authorize", - TokenURL: "https://github.com/login/oauth/access_token", - }, + Endpoint: endpoint, } } @@ -434,17 +458,21 @@ type OAuthUserInfo struct { // getUserInfo fetches user information from the OAuth provider func (os *OAuthService) getUserInfo(provider, accessToken string) (*OAuthUser, error) { var apiURL string - switch provider { - case "google": - apiURL = "https://www.googleapis.com/oauth2/v2/userinfo" - case "github": - apiURL = "https://api.github.com/user" - case "discord": - apiURL = "https://discord.com/api/users/@me" - case "spotify": - apiURL = "https://api.spotify.com/v1/me" - default: - return nil, fmt.Errorf("unknown provider: %s", provider) + if os.testUserInfoURL != "" { + apiURL = os.testUserInfoURL + } else { + switch provider { + case "google": + apiURL = "https://www.googleapis.com/oauth2/v2/userinfo" + case "github": + apiURL = "https://api.github.com/user" + case "discord": + apiURL = "https://discord.com/api/users/@me" + case "spotify": + apiURL = "https://api.spotify.com/v1/me" + default: + return nil, fmt.Errorf("unknown provider: %s", provider) + } } req, err := http.NewRequest("GET", apiURL, nil) diff --git a/veza-backend-api/package.json b/veza-backend-api/package.json index 987a8bf1e..75f4972c2 100644 --- a/veza-backend-api/package.json +++ b/veza-backend-api/package.json @@ -4,6 +4,7 @@ "scripts": { "build": "go build -v ./...", "test": "go test -v ./internal/handlers/... ./internal/services/... -short", - "lint": "test -z \"$(gofmt -l .)\" || (echo 'gofmt needed on:'; gofmt -l .; exit 1)" + "lint": "test -z \"$(gofmt -l .)\" || (echo 'gofmt needed on:'; gofmt -l .; exit 1)", + "test:integration": "go test -tags=integration -v ./tests/integration/... -count=1" } } diff --git a/veza-backend-api/tests/integration/logout_blacklist_test.go b/veza-backend-api/tests/integration/logout_blacklist_test.go new file mode 100644 index 000000000..ce671ccbf --- /dev/null +++ b/veza-backend-api/tests/integration/logout_blacklist_test.go @@ -0,0 +1,250 @@ +//go:build integration +// +build integration + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "veza-backend-api/internal/config" + "veza-backend-api/internal/core/auth" + "veza-backend-api/internal/database" + "veza-backend-api/internal/dto" + "veza-backend-api/internal/handlers" + "veza-backend-api/internal/middleware" + "veza-backend-api/internal/models" + "veza-backend-api/internal/repositories" + "veza-backend-api/internal/services" + "veza-backend-api/internal/validators" +) + +// setupLogoutBlacklistTestRouter creates a test router with AuthMiddleware + TokenBlacklist +func setupLogoutBlacklistTestRouter(t *testing.T) (*gin.Engine, *auth.AuthService, *services.TokenBlacklist, *gorm.DB, func()) { + gin.SetMode(gin.TestMode) + logger := zaptest.NewLogger(t) + + // Redis for TokenBlacklist + redisAddr := os.Getenv("REDIS_ADDR") + if redisAddr == "" { + redisAddr = "localhost:6379" + } + redisClient := redis.NewClient(&redis.Options{Addr: redisAddr}) + ctx := context.Background() + if err := redisClient.Ping(ctx).Err(); err != nil { + t.Skipf("Skipping test: Redis not available at %s: %v", redisAddr, err) + return nil, nil, nil, nil, func() {} + } + + tokenBlacklist := services.NewTokenBlacklist(redisClient) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + db.Exec("PRAGMA foreign_keys = ON") + + err = db.AutoMigrate( + &models.User{}, + &models.RefreshToken{}, + &models.Session{}, + &models.Role{}, + &models.Permission{}, + &models.UserRole{}, + &models.RolePermission{}, + ) + require.NoError(t, err) + + err = db.Exec(` + CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + token_hash TEXT NOT NULL, + email TEXT NOT NULL, + verified INTEGER NOT NULL DEFAULT 0, + used INTEGER NOT NULL DEFAULT 0, + verified_at TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `).Error + require.NoError(t, err) + + sqlDB, err := db.DB() + require.NoError(t, err) + dbWrapper := &database.Database{DB: sqlDB, GormDB: db, Logger: logger} + + emailValidator := validators.NewEmailValidator(db) + passwordValidator := validators.NewPasswordValidator() + passwordService := services.NewPasswordService(dbWrapper, logger) + jwtService, err := services.NewJWTService("test-secret-key-must-be-32-chars-long", "test-issuer", "test-audience") + require.NoError(t, err) + refreshTokenService := services.NewRefreshTokenService(db) + emailVerificationService := services.NewEmailVerificationService(dbWrapper, logger) + passwordResetService := services.NewPasswordResetService(dbWrapper, logger) + emailService := services.NewEmailService(dbWrapper, logger) + + authService := auth.NewAuthService( + db, emailValidator, passwordValidator, passwordService, + jwtService, refreshTokenService, emailVerificationService, + passwordResetService, emailService, nil, nil, logger, + ) + + sessionService := services.NewSessionService(dbWrapper, logger) + twoFactorService := services.NewTwoFactorService(dbWrapper, logger) + userRepo := repositories.NewGormUserRepository(db) + userService := services.NewUserServiceWithDB(userRepo, db) + auditService := services.NewAuditService(dbWrapper, logger) + permissionService := services.NewPermissionService(db) + + authMiddleware := middleware.NewAuthMiddleware( + sessionService, + auditService, + permissionService, + jwtService, + userService, + nil, // apiKeyService + tokenBlacklist, + logger, + ) + + cfg := &config.Config{ + CookiePath: "/", + CookieDomain: "", + CookieHttpOnly: true, + CookieSecure: false, + CookieSameSite: "lax", + JWTService: jwtService, + TokenBlacklist: tokenBlacklist, + } + + router := gin.New() + authGroup := router.Group("/auth") + { + authGroup.POST("/login", handlers.Login(authService, sessionService, twoFactorService, logger, cfg)) + authGroup.POST("/register", handlers.Register(authService, sessionService, logger, cfg)) + authGroup.POST("/refresh", handlers.Refresh(authService, sessionService, logger, cfg)) + authGroup.POST("/verify-email", handlers.VerifyEmail(authService)) + authGroup.GET("/check-username", handlers.CheckUsername(authService)) + + protected := authGroup.Group("") + protected.Use(authMiddleware.RequireAuth()) + protected.POST("/logout", handlers.Logout(authService, sessionService, logger, cfg)) + protected.GET("/me", handlers.GetMe(userService)) + } + + cleanup := func() { + redisClient.FlushDB(ctx) + redisClient.Close() + } + + return router, authService, tokenBlacklist, db, cleanup +} + +// TestLogoutBlacklist tests that after logout, the access token is blacklisted and returns 401 +func TestLogoutBlacklist(t *testing.T) { + router, _, _, db, cleanup := setupLogoutBlacklistTestRouter(t) + defer cleanup() + + if router == nil { + return + } + + // 1. Register + registerBody, _ := json.Marshal(dto.RegisterRequest{ + Email: "blacklist@test.com", + Username: "blacklisttest", + Password: "SecurePassword123!", + PasswordConfirm: "SecurePassword123!", + }) + registerReq := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewBuffer(registerBody)) + registerReq.Header.Set("Content-Type", "application/json") + registerW := httptest.NewRecorder() + router.ServeHTTP(registerW, registerReq) + require.Equal(t, http.StatusCreated, registerW.Code) + + // 2. Verify email + var user models.User + require.NoError(t, db.Where("email = ?", "blacklist@test.com").First(&user).Error) + var token string + err := db.Raw("SELECT token FROM email_verification_tokens WHERE user_id = ? AND used = 0 ORDER BY created_at DESC LIMIT 1", user.ID.String()).Scan(&token).Error + if err != nil { + t.Skip("email verification token not found") + return + } + verifyReq := httptest.NewRequest(http.MethodPost, "/auth/verify-email?token="+token, nil) + verifyW := httptest.NewRecorder() + router.ServeHTTP(verifyW, verifyReq) + require.Equal(t, http.StatusOK, verifyW.Code) + + // 3. Login + loginBody, _ := json.Marshal(dto.LoginRequest{ + Email: "blacklist@test.com", + Password: "SecurePassword123!", + RememberMe: false, + }) + loginReq := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewBuffer(loginBody)) + loginReq.Header.Set("Content-Type", "application/json") + loginW := httptest.NewRecorder() + router.ServeHTTP(loginW, loginReq) + require.Equal(t, http.StatusOK, loginW.Code) + + // Extract tokens from response and cookies + var loginResp handlers.APIResponse + require.NoError(t, json.Unmarshal(loginW.Body.Bytes(), &loginResp)) + loginDataBytes, _ := json.Marshal(loginResp.Data) + var loginData dto.LoginResponse + require.NoError(t, json.Unmarshal(loginDataBytes, &loginData)) + accessToken := loginData.Token.AccessToken + require.NotEmpty(t, accessToken) + + var refreshCookie *http.Cookie + for _, c := range loginW.Result().Cookies() { + if c.Name == "refresh_token" { + refreshCookie = c + break + } + } + require.NotNil(t, refreshCookie) + + // 4. Access /me with access token -> 200 + meReq := httptest.NewRequest(http.MethodGet, "/auth/me", nil) + meReq.Header.Set("Authorization", "Bearer "+accessToken) + meW := httptest.NewRecorder() + router.ServeHTTP(meW, meReq) + assert.Equal(t, http.StatusOK, meW.Code) + + // 5. Logout (with access token in Authorization and refresh in cookie) + logoutReq := httptest.NewRequest(http.MethodPost, "/auth/logout", nil) + logoutReq.Header.Set("Authorization", "Bearer "+accessToken) + logoutReq.AddCookie(refreshCookie) + logoutW := httptest.NewRecorder() + router.ServeHTTP(logoutW, logoutReq) + assert.Equal(t, http.StatusOK, logoutW.Code) + + // 6. Access /me with SAME access token -> 401 (blacklisted) + meReq2 := httptest.NewRequest(http.MethodGet, "/auth/me", nil) + meReq2.Header.Set("Authorization", "Bearer "+accessToken) + meW2 := httptest.NewRecorder() + router.ServeHTTP(meW2, meReq2) + assert.Equal(t, http.StatusUnauthorized, meW2.Code, "Blacklisted token should return 401") + + // 7. Refresh with old refresh token -> 401 (invalidated) + refreshReq := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil) + refreshReq.AddCookie(refreshCookie) + refreshW := httptest.NewRecorder() + router.ServeHTTP(refreshW, refreshReq) + assert.Equal(t, http.StatusUnauthorized, refreshW.Code, "Revoked refresh token should return 401") +} diff --git a/veza-backend-api/tests/integration/oauth_github_test.go b/veza-backend-api/tests/integration/oauth_github_test.go new file mode 100644 index 000000000..e56605cf3 --- /dev/null +++ b/veza-backend-api/tests/integration/oauth_github_test.go @@ -0,0 +1,186 @@ +//go:build integration +// +build integration + +package integration + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "veza-backend-api/internal/config" + "veza-backend-api/internal/database" + "veza-backend-api/internal/handlers" + "veza-backend-api/internal/models" + "veza-backend-api/internal/repositories" + "veza-backend-api/internal/services" + "veza-backend-api/internal/testutils" +) + +// setupOAuthGitHubTestRouter creates a test router with OAuth GitHub flow (mock provider) +func setupOAuthGitHubTestRouter(t *testing.T) (*gin.Engine, *services.OAuthService, *services.JWTService, *gorm.DB, *httptest.Server, func()) { + ctx := context.Background() + gin.SetMode(gin.TestMode) + logger := zaptest.NewLogger(t) + + dsn, err := testutils.GetTestContainerDB(ctx) + if err != nil { + t.Skipf("Skipping test: PostgreSQL testcontainer not available: %v", err) + return nil, nil, nil, nil, nil, func() {} + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + + sqlDB, err := db.DB() + require.NoError(t, err) + + dbWrapper := &database.Database{ + DB: sqlDB, + GormDB: db, + Logger: logger, + } + + // Mock OAuth provider: token exchange + userinfo (GitHub format) + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/token" && r.Method == http.MethodPost { + // PKCE: code_verifier is sent in the token exchange (verified by oauth2 library) + _ = r.ParseForm() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "mock_github_access_token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "mock_github_refresh_token", + }) + return + } + if r.URL.Path == "/userinfo" && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 12345, + "login": "ghuser", + "email": "oauth-github@test.com", + "name": "GitHub OAuth User", + }) + return + } + http.NotFound(w, r) + })) + + jwtService, err := services.NewJWTService("test-secret-key-must-be-32-chars-long", "test-issuer", "test-audience") + require.NoError(t, err) + + sessionService := services.NewSessionService(dbWrapper, logger) + userRepo := repositories.NewGormUserRepository(db) + userService := services.NewUserServiceWithDB(userRepo, db) + + oauthCfg := &services.OAuthServiceConfig{ + TestTokenURL: mockServer.URL + "/token", + TestUserInfoURL: mockServer.URL + "/userinfo", + FrontendURL: "http://localhost:5173", + AllowedDomains: []string{"*"}, + } + + oauthService := services.NewOAuthService(dbWrapper, logger, jwtService, sessionService, userService, oauthCfg) + oauthService.InitializeConfigs("", "", "test-client", "test-secret", "", "", "", "", "http://localhost:8080") + + cfg := &config.Config{ + CookiePath: "/", + CookieDomain: "", + CookieHttpOnly: true, + CookieSecure: false, + CookieSameSite: "lax", + JWTService: jwtService, + } + + oauthHandler := handlers.NewOAuthHandler(oauthService, logger, nil, "http://localhost:5173", cfg) + + router := gin.New() + authGroup := router.Group("/auth") + oauthGroup := authGroup.Group("/oauth") + { + oauthGroup.GET("/:provider", oauthHandler.InitiateOAuth) + oauthGroup.GET("/:provider/callback", oauthHandler.OAuthCallback) + } + + cleanup := func() { + mockServer.Close() + } + + return router, oauthService, jwtService, db, mockServer, cleanup +} + +// TestOAuthGitHubFlow tests the full OAuth GitHub flow with mocked provider and PKCE +func TestOAuthGitHubFlow(t *testing.T) { + router, oauthService, jwtService, db, _, cleanup := setupOAuthGitHubTestRouter(t) + defer cleanup() + + if router == nil { + return + } + + // Verify PKCE: GetAuthURL should include code_challenge in the URL + authURL, err := oauthService.GetAuthURL("github") + require.NoError(t, err) + parsed, err := url.Parse(authURL) + require.NoError(t, err) + codeChallenge := parsed.Query().Get("code_challenge") + codeChallengeMethod := parsed.Query().Get("code_challenge_method") + assert.NotEmpty(t, codeChallenge, "code_challenge must be present in auth URL for PKCE") + assert.Equal(t, "S256", codeChallengeMethod, "code_challenge_method should be S256") + + // Generate state token (simulate InitiateOAuth - we need the state for callback) + stateToken, codeVerifier, err := oauthService.GenerateStateToken("github", "http://localhost:5173") + require.NoError(t, err) + require.NotEmpty(t, stateToken) + require.NotEmpty(t, codeVerifier) + + // Simulate OAuth callback with mock code + callbackURL := "/auth/oauth/github/callback?code=mock_github_code&state=" + stateToken + req := httptest.NewRequest(http.MethodGet, callbackURL, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusTemporaryRedirect, w.Code, "Expected redirect after OAuth callback") + + // Verify cookies are set + cookies := w.Result().Cookies() + var accessTokenCookie, refreshTokenCookie *http.Cookie + for _, c := range cookies { + if c.Name == "access_token" { + accessTokenCookie = c + } + if c.Name == "refresh_token" { + refreshTokenCookie = c + } + } + + require.NotNil(t, accessTokenCookie, "access_token cookie should be set") + require.NotNil(t, refreshTokenCookie, "refresh_token cookie should be set") + assert.NotEmpty(t, accessTokenCookie.Value) + assert.True(t, accessTokenCookie.HttpOnly) + + // Validate JWT + claims, err := jwtService.ValidateToken(accessTokenCookie.Value) + require.NoError(t, err) + assert.NotEqual(t, claims.UserID, uuid.Nil) + + // Verify user was created with GitHub data + var user models.User + err = db.Where("email = ?", "oauth-github@test.com").First(&user).Error + require.NoError(t, err) + assert.Equal(t, "oauth-github@test.com", user.Email) +} diff --git a/veza-backend-api/tests/integration/oauth_google_test.go b/veza-backend-api/tests/integration/oauth_google_test.go new file mode 100644 index 000000000..cc8d382bb --- /dev/null +++ b/veza-backend-api/tests/integration/oauth_google_test.go @@ -0,0 +1,179 @@ +//go:build integration +// +build integration + +package integration + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "veza-backend-api/internal/config" + "veza-backend-api/internal/database" + "veza-backend-api/internal/handlers" + "veza-backend-api/internal/models" + "veza-backend-api/internal/repositories" + "veza-backend-api/internal/services" + "veza-backend-api/internal/testutils" +) + +// setupOAuthGoogleTestRouter creates a test router with OAuth Google flow (mock provider) +func setupOAuthGoogleTestRouter(t *testing.T) (*gin.Engine, *services.OAuthService, *services.JWTService, *gorm.DB, *httptest.Server, func()) { + ctx := context.Background() + gin.SetMode(gin.TestMode) + logger := zaptest.NewLogger(t) + + dsn, err := testutils.GetTestContainerDB(ctx) + if err != nil { + t.Skipf("Skipping test: PostgreSQL testcontainer not available: %v", err) + return nil, nil, nil, nil, nil, func() {} + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + + sqlDB, err := db.DB() + require.NoError(t, err) + + dbWrapper := &database.Database{ + DB: sqlDB, + GormDB: db, + Logger: logger, + } + + // Mock OAuth provider: token exchange + userinfo + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/token" && r.Method == http.MethodPost { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "mock_access_token_123", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "mock_refresh_token", + }) + return + } + if r.URL.Path == "/userinfo" && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "google123", + "email": "oauth-google@test.com", + "name": "OAuth Google User", + }) + return + } + http.NotFound(w, r) + })) + + jwtService, err := services.NewJWTService("test-secret-key-must-be-32-chars-long", "test-issuer", "test-audience") + require.NoError(t, err) + + sessionService := services.NewSessionService(dbWrapper, logger) + userRepo := repositories.NewGormUserRepository(db) + userService := services.NewUserServiceWithDB(userRepo, db) + + oauthCfg := &services.OAuthServiceConfig{ + TestTokenURL: mockServer.URL + "/token", + TestUserInfoURL: mockServer.URL + "/userinfo", + FrontendURL: "http://localhost:5173", + AllowedDomains: []string{"*"}, + } + + oauthService := services.NewOAuthService(dbWrapper, logger, jwtService, sessionService, userService, oauthCfg) + oauthService.InitializeConfigs("test-client", "test-secret", "", "", "", "", "", "", "http://localhost:8080") + + cfg := &config.Config{ + CookiePath: "/", + CookieDomain: "", + CookieHttpOnly: true, + CookieSecure: false, + CookieSameSite: "lax", + JWTService: jwtService, + } + + oauthHandler := handlers.NewOAuthHandler(oauthService, logger, nil, "http://localhost:5173", cfg) + + router := gin.New() + authGroup := router.Group("/auth") + oauthGroup := authGroup.Group("/oauth") + { + oauthGroup.GET("/:provider", oauthHandler.InitiateOAuth) + oauthGroup.GET("/:provider/callback", oauthHandler.OAuthCallback) + } + + cleanup := func() { + mockServer.Close() + } + + return router, oauthService, jwtService, db, mockServer, cleanup +} + +// TestOAuthGoogleFlow tests the full OAuth Google flow with mocked provider +func TestOAuthGoogleFlow(t *testing.T) { + router, oauthService, jwtService, db, _, cleanup := setupOAuthGoogleTestRouter(t) + defer cleanup() + + if router == nil { + return + } + + // Generate state token and insert into DB (simulate InitiateOAuth step) + stateToken, codeVerifier, err := oauthService.GenerateStateToken("google", "http://localhost:5173") + require.NoError(t, err) + require.NotEmpty(t, stateToken) + require.NotEmpty(t, codeVerifier) + + // Simulate OAuth callback with mock code + callbackURL := "/auth/oauth/google/callback?code=mock_code&state=" + stateToken + req := httptest.NewRequest(http.MethodGet, callbackURL, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusTemporaryRedirect, w.Code, "Expected redirect after OAuth callback") + + // Verify cookies are set + cookies := w.Result().Cookies() + var accessTokenCookie, refreshTokenCookie *http.Cookie + for _, c := range cookies { + if c.Name == "access_token" { + accessTokenCookie = c + } + if c.Name == "refresh_token" { + refreshTokenCookie = c + } + } + + require.NotNil(t, accessTokenCookie, "access_token cookie should be set") + require.NotNil(t, refreshTokenCookie, "refresh_token cookie should be set") + assert.NotEmpty(t, accessTokenCookie.Value, "access_token should not be empty") + assert.NotEmpty(t, refreshTokenCookie.Value, "refresh_token should not be empty") + assert.True(t, accessTokenCookie.HttpOnly, "access_token should be httpOnly") + assert.True(t, refreshTokenCookie.HttpOnly, "refresh_token should be httpOnly") + + // Validate JWT with JWTService + claims, err := jwtService.ValidateToken(accessTokenCookie.Value) + require.NoError(t, err) + assert.NotEqual(t, claims.UserID, uuid.Nil) + + // Verify session exists in DB + var sessionCount int64 + err = db.Table("sessions").Count(&sessionCount).Error + require.NoError(t, err) + assert.GreaterOrEqual(t, sessionCount, int64(1), "Session should be created") + + // Verify user was created + var user models.User + err = db.Where("email = ?", "oauth-google@test.com").First(&user).Error + require.NoError(t, err) + assert.Equal(t, "oauth-google@test.com", user.Email) +} diff --git a/veza-backend-api/tests/integration/resume_upload_test.go b/veza-backend-api/tests/integration/resume_upload_test.go index 58f9eab86..d3c4fab71 100644 --- a/veza-backend-api/tests/integration/resume_upload_test.go +++ b/veza-backend-api/tests/integration/resume_upload_test.go @@ -27,7 +27,7 @@ import ( "veza-backend-api/internal/core/track" "veza-backend-api/internal/models" "veza-backend-api/internal/services" - "veza-backend-api/tests/testutils" + "veza-backend-api/internal/testutils" ) // TestResumeUploadEndpoint_ChunkedUploads tests the GET /api/v1/tracks/resume/:uploadId endpoint diff --git a/veza-backend-api/tests/integration/token_refresh_test.go b/veza-backend-api/tests/integration/token_refresh_test.go new file mode 100644 index 000000000..817e2cfcd --- /dev/null +++ b/veza-backend-api/tests/integration/token_refresh_test.go @@ -0,0 +1,233 @@ +//go:build integration +// +build integration + +package integration + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "veza-backend-api/internal/config" + "veza-backend-api/internal/core/auth" + "veza-backend-api/internal/database" + "veza-backend-api/internal/dto" + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/handlers" + "veza-backend-api/internal/models" + "veza-backend-api/internal/repositories" + "veza-backend-api/internal/services" + "veza-backend-api/internal/validators" + + "github.com/google/uuid" +) + +// setupTokenRefreshTestRouter creates a test router for token refresh E2E (cookie-based) +func setupTokenRefreshTestRouter(t *testing.T) (*gin.Engine, *auth.AuthService, *config.Config, *gorm.DB, func()) { + gin.SetMode(gin.TestMode) + logger := zaptest.NewLogger(t) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + db.Exec("PRAGMA foreign_keys = ON") + + err = db.AutoMigrate( + &models.User{}, + &models.RefreshToken{}, + &models.Session{}, + &models.Role{}, + &models.Permission{}, + &models.UserRole{}, + &models.RolePermission{}, + ) + require.NoError(t, err) + + err = db.Exec(` + CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + token_hash TEXT NOT NULL, + email TEXT NOT NULL, + verified INTEGER NOT NULL DEFAULT 0, + used INTEGER NOT NULL DEFAULT 0, + verified_at TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `).Error + require.NoError(t, err) + + sqlDB, err := db.DB() + require.NoError(t, err) + dbWrapper := &database.Database{DB: sqlDB, GormDB: db, Logger: logger} + + emailValidator := validators.NewEmailValidator(db) + passwordValidator := validators.NewPasswordValidator() + passwordService := services.NewPasswordService(dbWrapper, logger) + jwtService, err := services.NewJWTService("test-secret-key-must-be-32-chars-long", "test-issuer", "test-audience") + require.NoError(t, err) + refreshTokenService := services.NewRefreshTokenService(db) + emailVerificationService := services.NewEmailVerificationService(dbWrapper, logger) + passwordResetService := services.NewPasswordResetService(dbWrapper, logger) + emailService := services.NewEmailService(dbWrapper, logger) + + authService := auth.NewAuthService( + db, emailValidator, passwordValidator, passwordService, + jwtService, refreshTokenService, emailVerificationService, + passwordResetService, emailService, nil, nil, logger, + ) + + sessionService := services.NewSessionService(dbWrapper, logger) + twoFactorService := services.NewTwoFactorService(dbWrapper, logger) + userRepo := repositories.NewGormUserRepository(db) + userService := services.NewUserServiceWithDB(userRepo, db) + + cfg := &config.Config{ + CookiePath: "/", + CookieDomain: "", + CookieHttpOnly: true, + CookieSecure: false, + CookieSameSite: "lax", + JWTService: jwtService, + } + + router := gin.New() + authGroup := router.Group("/auth") + { + authGroup.POST("/login", handlers.Login(authService, sessionService, twoFactorService, logger, cfg)) + authGroup.POST("/register", handlers.Register(authService, sessionService, logger, cfg)) + authGroup.POST("/refresh", handlers.Refresh(authService, sessionService, logger, cfg)) + authGroup.POST("/logout", handlers.Logout(authService, sessionService, logger, cfg)) + authGroup.POST("/verify-email", handlers.VerifyEmail(authService)) + authGroup.GET("/check-username", handlers.CheckUsername(authService)) + + protected := authGroup.Group("") + protected.Use(func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + handlers.RespondWithAppError(c, apperrors.NewUnauthorizedError("Unauthorized")) + c.Abort() + return + } + if !strings.HasPrefix(authHeader, "Bearer ") { + handlers.RespondWithAppError(c, apperrors.NewUnauthorizedError("Invalid authorization header")) + c.Abort() + return + } + token := strings.TrimPrefix(authHeader, "Bearer ") + claims, err := authService.JWTService.ValidateToken(token) + if err != nil { + handlers.RespondWithAppError(c, apperrors.NewUnauthorizedError("Invalid token")) + c.Abort() + return + } + if claims.UserID == uuid.Nil { + handlers.RespondWithAppError(c, apperrors.NewUnauthorizedError("Invalid token claims")) + c.Abort() + return + } + c.Set("user_id", claims.UserID) + c.Next() + }) + protected.GET("/me", handlers.GetMe(userService)) + } + + return router, authService, cfg, db, func() {} +} + +// TestTokenRefreshViaCookies tests refresh flow using httpOnly cookies only (no body) +func TestTokenRefreshViaCookies(t *testing.T) { + router, _, _, db, cleanup := setupTokenRefreshTestRouter(t) + defer cleanup() + + // 1. Register + registerBody, _ := json.Marshal(dto.RegisterRequest{ + Email: "refresh@test.com", + Username: "refreshtest", + Password: "SecurePassword123!", + PasswordConfirm: "SecurePassword123!", + }) + registerReq := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewBuffer(registerBody)) + registerReq.Header.Set("Content-Type", "application/json") + registerW := httptest.NewRecorder() + router.ServeHTTP(registerW, registerReq) + require.Equal(t, http.StatusCreated, registerW.Code) + + // 2. Verify email + var user models.User + require.NoError(t, db.Where("email = ?", "refresh@test.com").First(&user).Error) + var token string + err := db.Raw("SELECT token FROM email_verification_tokens WHERE user_id = ? AND used = 0 ORDER BY created_at DESC LIMIT 1", user.ID.String()).Scan(&token).Error + if err != nil { + t.Skip("email verification token not found") + return + } + verifyReq := httptest.NewRequest(http.MethodPost, "/auth/verify-email?token="+token, nil) + verifyW := httptest.NewRecorder() + router.ServeHTTP(verifyW, verifyReq) + require.Equal(t, http.StatusOK, verifyW.Code) + + // 3. Login + loginBody, _ := json.Marshal(dto.LoginRequest{ + Email: "refresh@test.com", + Password: "SecurePassword123!", + RememberMe: false, + }) + loginReq := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewBuffer(loginBody)) + loginReq.Header.Set("Content-Type", "application/json") + loginW := httptest.NewRecorder() + router.ServeHTTP(loginW, loginReq) + require.Equal(t, http.StatusOK, loginW.Code) + + // Extract refresh_token from cookies + loginCookies := loginW.Result().Cookies() + var refreshCookie *http.Cookie + for _, c := range loginCookies { + if c.Name == "refresh_token" { + refreshCookie = c + break + } + } + require.NotNil(t, refreshCookie, "refresh_token cookie should be set after login") + require.NotEmpty(t, refreshCookie.Value) + assert.True(t, refreshCookie.HttpOnly) + + // 4. Refresh using ONLY cookie (no body) + refreshReq := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil) + refreshReq.AddCookie(refreshCookie) + refreshW := httptest.NewRecorder() + router.ServeHTTP(refreshW, refreshReq) + + require.Equal(t, http.StatusOK, refreshW.Code) + + // 5. Verify new cookies are set + refreshCookies := refreshW.Result().Cookies() + var newAccessCookie *http.Cookie + for _, c := range refreshCookies { + if c.Name == "access_token" { + newAccessCookie = c + break + } + } + require.NotNil(t, newAccessCookie, "access_token cookie should be updated") + require.NotEmpty(t, newAccessCookie.Value) + assert.True(t, newAccessCookie.HttpOnly) + + // 6. Use new access token for protected route + meReq := httptest.NewRequest(http.MethodGet, "/auth/me", nil) + meReq.Header.Set("Authorization", "Bearer "+newAccessCookie.Value) + meW := httptest.NewRecorder() + router.ServeHTTP(meW, meReq) + assert.Equal(t, http.StatusOK, meW.Code) +} diff --git a/veza-backend-api/tests/integration/track_quota_test.go b/veza-backend-api/tests/integration/track_quota_test.go index 148db2a9b..d1774a1b0 100644 --- a/veza-backend-api/tests/integration/track_quota_test.go +++ b/veza-backend-api/tests/integration/track_quota_test.go @@ -22,7 +22,7 @@ import ( "veza-backend-api/internal/core/track" "veza-backend-api/internal/models" "veza-backend-api/internal/services" - "veza-backend-api/tests/testutils" + "veza-backend-api/internal/testutils" ) // TestTrackQuotaEndpoint_GetQuota tests the GET /api/v1/tracks/quota/:id endpoint @@ -99,7 +99,7 @@ func TestTrackQuotaEndpoint_GetQuota(t *testing.T) { track1 := &models.Track{ ID: uuid.New(), Title: "Test Track 1", - CreatorID: userID, + UserID: userID, FileSize: 5 * 1024 * 1024, // 5MB IsPublic: true, } @@ -108,7 +108,7 @@ func TestTrackQuotaEndpoint_GetQuota(t *testing.T) { track2 := &models.Track{ ID: uuid.New(), Title: "Test Track 2", - CreatorID: userID, + UserID: userID, FileSize: 10 * 1024 * 1024, // 10MB IsPublic: true, }