feat(auth): v0.911 Keystone - OAuth and auth integration tests
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s

- 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
This commit is contained in:
senke 2026-02-27 09:58:53 +01:00
parent f9120c322b
commit 4720bb20b2
10 changed files with 917 additions and 23 deletions

View file

@ -1 +1 @@
0.903
0.911

View file

@ -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",

View file

@ -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)

View file

@ -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"
}
}

View file

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

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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

View file

@ -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)
}

View file

@ -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,
}