//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") }