first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:41:46 +03:00
commit b6e74bd024
56 changed files with 16114 additions and 0 deletions

View File

@@ -0,0 +1,823 @@
package controllers
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
githuboauth "golang.org/x/oauth2/github"
"golang.org/x/oauth2/google"
"gorm.io/gorm"
"goaresv3/app/accounts/models"
"goaresv3/config"
jwtHelper "goaresv3/pkg/jwt"
"goaresv3/pkg/mailer"
)
const googleOAuthStateCookieName = "google_oauth_state"
const githubOAuthStateCookieName = "github_oauth_state"
type socialUserProfile struct {
ProviderID string
Email string
Name string
AvatarURL string
EmailVerified bool
}
type googleUserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
}
var exchangeGoogleCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) {
return cfg.Exchange(ctx, code)
}
var fetchGoogleUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*googleUserInfo, error) {
client := cfg.Client(ctx, token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("userinfo status: %d", resp.StatusCode)
}
var info googleUserInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, err
}
return &info, nil
}
type githubUserInfo struct {
ID int64 `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
type githubEmailInfo struct {
Email string `json:"email"`
Verified bool `json:"verified"`
Primary bool `json:"primary"`
}
var exchangeGitHubCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) {
return cfg.Exchange(ctx, code)
}
var fetchGitHubUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*socialUserProfile, error) {
client := cfg.Client(ctx, token)
userResp, err := client.Get("https://api.github.com/user")
if err != nil {
return nil, err
}
defer userResp.Body.Close()
if userResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("github userinfo status: %d", userResp.StatusCode)
}
var user githubUserInfo
if err := json.NewDecoder(userResp.Body).Decode(&user); err != nil {
return nil, err
}
email := strings.TrimSpace(user.Email)
verifiedFromProvider := email != ""
emailsResp, err := client.Get("https://api.github.com/user/emails")
if err == nil {
defer emailsResp.Body.Close()
if emailsResp.StatusCode == http.StatusOK {
var emails []githubEmailInfo
if err := json.NewDecoder(emailsResp.Body).Decode(&emails); err == nil {
for _, e := range emails {
if e.Primary && e.Verified && strings.TrimSpace(e.Email) != "" {
email = strings.TrimSpace(e.Email)
verifiedFromProvider = true
break
}
}
if email == "" {
for _, e := range emails {
if e.Verified && strings.TrimSpace(e.Email) != "" {
email = strings.TrimSpace(e.Email)
verifiedFromProvider = true
break
}
}
}
}
}
}
if user.ID == 0 || email == "" {
return nil, fmt.Errorf("github profile is missing required fields")
}
name := strings.TrimSpace(user.Name)
if name == "" {
name = strings.TrimSpace(user.Login)
}
if name == "" {
parts := strings.Split(email, "@")
if len(parts) > 0 {
name = parts[0]
}
}
return &socialUserProfile{
ProviderID: fmt.Sprintf("%d", user.ID),
Email: email,
Name: name,
AvatarURL: strings.TrimSpace(user.AvatarURL),
EmailVerified: verifiedFromProvider,
}, nil
}
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// ── Request DTOs ─────────────────────────────────────────────────────────────
type RegisterRequest struct {
UserName string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
ConfirmPassword string `json:"confirm_password" binding:"required,min=8"`
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
func generateEmailVerifyToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func generateOAuthStateToken() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func envValue(key string) string {
v := strings.TrimSpace(os.Getenv(key))
return strings.Trim(v, "'\"")
}
func fallbackRedirectURL(path string) string {
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/")
if baseURL == "" {
baseURL = "http://localhost:8080"
}
return baseURL + path
}
func getGoogleOAuthConfig() (*oauth2.Config, error) {
clientID := envValue("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY")
clientSecret := envValue("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET")
redirectURL := envValue("SOCIAL_AUTH_GOOGLE_REDIRECT_URL")
if redirectURL == "" {
redirectURL = fallbackRedirectURL("/api/v1/auth/google/callback")
}
if clientID == "" || clientSecret == "" || redirectURL == "" {
return nil, fmt.Errorf("google oauth configuration is incomplete")
}
scopes := []string{"openid", "email", "profile"}
if rawScopes := envValue("SOCIAL_AUTH_GOOGLE_SCOPES"); rawScopes != "" {
scopes = scopes[:0]
for _, scope := range strings.Split(rawScopes, ",") {
s := strings.TrimSpace(scope)
if s != "" {
scopes = append(scopes, s)
}
}
if len(scopes) == 0 {
scopes = []string{"openid", "email", "profile"}
}
}
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: scopes,
Endpoint: google.Endpoint,
}, nil
}
func getGitHubOAuthConfig() (*oauth2.Config, error) {
clientID := envValue("SOCIAL_AUTH_GITHUB_KEY")
clientSecret := envValue("SOCIAL_AUTH_GITHUB_SECRET")
redirectURL := envValue("SOCIAL_AUTH_GITHUB_REDIRECT_URL")
if redirectURL == "" {
redirectURL = fallbackRedirectURL("/api/v1/auth/github/callback")
}
if clientID == "" || clientSecret == "" || redirectURL == "" {
return nil, fmt.Errorf("github oauth configuration is incomplete")
}
scopes := []string{"read:user", "user:email"}
if rawScopes := envValue("SOCIAL_AUTH_GITHUB_SCOPES"); rawScopes != "" {
scopes = scopes[:0]
for _, scope := range strings.Split(rawScopes, ",") {
s := strings.TrimSpace(scope)
if s != "" {
scopes = append(scopes, s)
}
}
if len(scopes) == 0 {
scopes = []string{"read:user", "user:email"}
}
}
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: scopes,
Endpoint: githuboauth.Endpoint,
}, nil
}
func resolveUserNameFromGoogleProfile(profile *googleUserInfo) string {
if profile == nil {
return ""
}
if profile.Name != "" {
return profile.Name
}
if profile.GivenName != "" {
return profile.GivenName
}
if profile.Email != "" {
parts := strings.Split(profile.Email, "@")
if len(parts) > 0 {
return parts[0]
}
}
return "google-user"
}
func completeSocialLogin(c *gin.Context, provider string, profile *socialUserProfile) {
if profile == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "provider profile is missing"})
return
}
if profile.ProviderID == "" || profile.Email == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "provider profile is missing required fields"})
return
}
if !profile.EmailVerified {
c.JSON(http.StatusForbidden, gin.H{"error": "provider email is not verified"})
return
}
var user models.User
err := config.DB.Transaction(func(tx *gorm.DB) error {
var social models.SocialAccount
err := tx.Where("provider = ? AND provider_id = ?", provider, profile.ProviderID).First(&social).Error
if err == nil {
if err := tx.First(&user, social.UserID).Error; err != nil {
return err
}
} else if err == gorm.ErrRecordNotFound {
err = tx.Where("email = ?", profile.Email).First(&user).Error
if err == gorm.ErrRecordNotFound {
verified := true
user = models.User{
UserName: profile.Name,
Email: profile.Email,
EmailVerified: &verified,
EmailVerifiedAt: ptrTime(time.Now()),
}
if err := tx.Create(&user).Error; err != nil {
return err
}
} else if err != nil {
return err
}
social = models.SocialAccount{
UserID: uint64(user.ID),
Provider: provider,
ProviderID: profile.ProviderID,
Email: profile.Email,
Name: profile.Name,
AvatarURL: profile.AvatarURL,
}
if err := tx.Create(&social).Error; err != nil {
return err
}
} else {
return err
}
if !user.IsEmailVerified() {
verified := true
now := time.Now()
if err := tx.Model(&user).Updates(map[string]any{
"email_verified": &verified,
"email_verified_at": &now,
"email_verify_token": "",
}).Error; err != nil {
return err
}
user.EmailVerified = &verified
user.EmailVerifiedAt = &now
user.EmailVerifyToken = ""
}
return tx.Model(&models.SocialAccount{}).
Where("user_id = ? AND provider = ? AND provider_id = ?", user.ID, provider, profile.ProviderID).
Updates(map[string]any{
"email": profile.Email,
"name": profile.Name,
"avatar_url": profile.AvatarURL,
}).Error
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process social login"})
return
}
accessToken, err := jwtHelper.GenerateAccessToken(user.ID, user.Email, user.UserName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"})
return
}
refreshToken, err := jwtHelper.GenerateRefreshToken(user.ID, user.Email, user.UserName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate refresh token"})
return
}
c.JSON(http.StatusOK, gin.H{
"access_token": accessToken,
"refresh_token": refreshToken,
"token_type": "Bearer",
"provider": provider,
})
}
// ── Handlers ──────────────────────────────────────────────────────────────────
// Register godoc
// @Summary Register a new user
// @Description Creates a user and sends an email verification link.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body RegisterRequest true "register payload"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 409 {object} map[string]string
// @Router /api/v1/auth/register [post]
func Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Password != req.ConfirmPassword {
c.JSON(http.StatusBadRequest, gin.H{"error": "password and confirm_password do not match"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not process password"})
return
}
verifyToken, err := generateEmailVerifyToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate verification token"})
return
}
verified := false
user := models.User{
UserName: req.UserName,
Email: req.Email,
Password: string(hashed),
EmailVerified: &verified,
EmailVerifyToken: verifyToken,
}
if result := config.DB.Create(&user); result.Error != nil {
c.JSON(http.StatusConflict, gin.H{"error": "email already in use"})
return
}
baseURL := strings.TrimRight(os.Getenv("APP_BASE_URL"), "/")
if baseURL == "" {
baseURL = "http://localhost:8080"
}
verifyURL := fmt.Sprintf("%s/api/v1/auth/verify-email?token=%s", baseURL, verifyToken)
body := fmt.Sprintf("Hello %s,\n\nPlease verify your email by clicking the link below:\n%s\n\nIf you did not create this account, you can ignore this email.", user.UserName, verifyURL)
if err := mailer.Send(user.Email, "Verify your email", body); err != nil {
_ = config.DB.Delete(&user).Error
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send verification email"})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "user created. please verify your email",
"user_id": user.ID,
})
}
// VerifyEmail godoc
// @Summary Verify email address
// @Description Activates account using email verification token.
// @Tags Auth
// @Produce json
// @Param token query string true "email verification token"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/auth/verify-email [get]
func VerifyEmail(c *gin.Context) {
token := strings.TrimSpace(c.Query("token"))
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "verification token is required"})
return
}
now := time.Now()
verified := true
result := config.DB.Model(&models.User{}).
Where("email_verify_token = ?", token).
Updates(map[string]interface{}{
"email_verified": &verified,
"email_verify_token": "",
"email_verified_at": &now,
})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not verify email"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired verification token"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "email verified successfully"})
}
// Login godoc
// @Summary Login with email/password
// @Description Returns access and refresh tokens.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body LoginRequest true "login payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /api/v1/auth/login [post]
func Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if result := config.DB.Where("email = ?", req.Email).First(&user); result.Error != nil {
// Return generic message to avoid user enumeration
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"})
return
}
if !user.IsEmailVerified() {
c.JSON(http.StatusForbidden, gin.H{"error": "email is not verified"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"})
return
}
accessToken, err := jwtHelper.GenerateAccessToken(user.ID, user.Email, user.UserName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"})
return
}
refreshToken, err := jwtHelper.GenerateRefreshToken(user.ID, user.Email, user.UserName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate refresh token"})
return
}
c.JSON(http.StatusOK, gin.H{
"access_token": accessToken,
"refresh_token": refreshToken,
"token_type": "Bearer",
})
}
// GoogleLogin godoc
// @Summary Start Google OAuth login
// @Description Returns Google authorization URL and sets state cookie for CSRF protection.
// @Tags Auth
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Failure 503 {object} map[string]string
// @Router /api/v1/auth/google/login [get]
func GoogleLogin(c *gin.Context) {
cfg, err := getGoogleOAuthConfig()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "google oauth is not configured"})
return
}
state, err := generateOAuthStateToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not initialize google login"})
return
}
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(googleOAuthStateCookieName, state, 600, "/", "", false, true)
authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOnline)
c.JSON(http.StatusOK, gin.H{"auth_url": authURL})
}
// GoogleCallback godoc
// @Summary Google OAuth callback
// @Description Exchanges Google code and returns local access/refresh tokens.
// @Tags Auth
// @Produce json
// @Param state query string true "oauth state"
// @Param code query string true "authorization code"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/auth/google/callback [get]
func GoogleCallback(c *gin.Context) {
cfg, err := getGoogleOAuthConfig()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "google oauth is not configured"})
return
}
if oauthErr := strings.TrimSpace(c.Query("error")); oauthErr != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "google authorization failed"})
return
}
state := strings.TrimSpace(c.Query("state"))
code := strings.TrimSpace(c.Query("code"))
if state == "" || code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "state and code are required"})
return
}
storedState, err := c.Cookie(googleOAuthStateCookieName)
if err != nil || storedState == "" || storedState != state {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid oauth state"})
return
}
c.SetCookie(googleOAuthStateCookieName, "", -1, "/", "", false, true)
token, err := exchangeGoogleCode(c.Request.Context(), cfg, code)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to exchange google code"})
return
}
profile, err := fetchGoogleUserInfo(c.Request.Context(), cfg, token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to fetch google profile"})
return
}
if profile.Sub == "" || profile.Email == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "google profile is missing required fields"})
return
}
completeSocialLogin(c, "google", &socialUserProfile{
ProviderID: profile.Sub,
Email: profile.Email,
Name: resolveUserNameFromGoogleProfile(profile),
AvatarURL: profile.Picture,
EmailVerified: profile.EmailVerified,
})
}
// GitHubLogin godoc
// @Summary Start GitHub OAuth login
// @Description Returns GitHub authorization URL and sets state cookie for CSRF protection.
// @Tags Auth
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Failure 503 {object} map[string]string
// @Router /api/v1/auth/github/login [get]
func GitHubLogin(c *gin.Context) {
cfg, err := getGitHubOAuthConfig()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "github oauth is not configured"})
return
}
state, err := generateOAuthStateToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not initialize github login"})
return
}
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(githubOAuthStateCookieName, state, 600, "/", "", false, true)
authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOnline)
c.JSON(http.StatusOK, gin.H{"auth_url": authURL})
}
// GitHubCallback godoc
// @Summary GitHub OAuth callback
// @Description Exchanges GitHub code and returns local access/refresh tokens.
// @Tags Auth
// @Produce json
// @Param state query string true "oauth state"
// @Param code query string true "authorization code"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/auth/github/callback [get]
func GitHubCallback(c *gin.Context) {
cfg, err := getGitHubOAuthConfig()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "github oauth is not configured"})
return
}
if oauthErr := strings.TrimSpace(c.Query("error")); oauthErr != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "github authorization failed"})
return
}
state := strings.TrimSpace(c.Query("state"))
code := strings.TrimSpace(c.Query("code"))
if state == "" || code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "state and code are required"})
return
}
storedState, err := c.Cookie(githubOAuthStateCookieName)
if err != nil || storedState == "" || storedState != state {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid oauth state"})
return
}
c.SetCookie(githubOAuthStateCookieName, "", -1, "/", "", false, true)
token, err := exchangeGitHubCode(c.Request.Context(), cfg, code)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to exchange github code"})
return
}
profile, err := fetchGitHubUserInfo(c.Request.Context(), cfg, token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to fetch github profile"})
return
}
completeSocialLogin(c, "github", profile)
}
func ptrTime(t time.Time) *time.Time {
return &t
}
// RefreshToken godoc
// @Summary Refresh access token
// @Description Exchanges a valid refresh token for a new access token.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body RefreshRequest true "refresh payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /api/v1/auth/refresh [post]
func RefreshToken(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
claims, err := jwtHelper.ValidateToken(req.RefreshToken, os.Getenv("JWT_REFRESH_SECRET"))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"})
return
}
var user models.User
if result := config.DB.Select("email", "user_name", "email_verified").First(&user, claims.UserID); result.Error != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user"})
return
}
if !user.IsEmailVerified() {
c.JSON(http.StatusForbidden, gin.H{"error": "email is not verified"})
return
}
userName := claims.UserName
if userName == "" {
if result := config.DB.Select("user_name").First(&user, claims.UserID); result.Error == nil {
userName = user.UserName
}
}
if userName == "" {
userName = user.UserName
}
accessToken, err := jwtHelper.GenerateAccessToken(claims.UserID, user.Email, userName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"})
return
}
c.JSON(http.StatusOK, gin.H{
"access_token": accessToken,
"token_type": "Bearer",
})
}
// Me godoc
// @Summary Get current user info
// @Description Returns user_id, email and username from the authenticated user.
// @Tags User
// @Security BearerAuth
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]string
// @Router /api/v1/me [get]
func Me(c *gin.Context) {
userName := c.GetString("username")
if userName == "" {
var user models.User
if result := config.DB.Select("user_name").First(&user, c.GetUint("user_id")); result.Error == nil {
userName = user.UserName
}
}
c.JSON(http.StatusOK, gin.H{
"user_id": c.GetUint("user_id"),
"email": c.GetString("email"),
"username": userName,
})
}

View File

@@ -0,0 +1,652 @@
package controllers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"goaresv3/app/accounts/models"
"goaresv3/config"
jwtHelper "goaresv3/pkg/jwt"
)
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite db: %v", err)
}
if err := db.AutoMigrate(&models.User{}); err != nil {
t.Fatalf("failed to migrate user model: %v", err)
}
if err := db.AutoMigrate(&models.SocialAccount{}); err != nil {
t.Fatalf("failed to migrate social account model: %v", err)
}
config.DB = db
return db
}
func boolPtr(v bool) *bool {
return &v
}
func TestVerifyEmailSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
user := models.User{UserName: "u1", Email: "u1@example.com", EmailVerified: boolPtr(false), EmailVerifyToken: "tok-123"}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
r := gin.New()
r.GET("/api/v1/auth/verify-email", VerifyEmail)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token=tok-123", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var updated models.User
if err := db.First(&updated, user.ID).Error; err != nil {
t.Fatalf("failed to fetch updated user: %v", err)
}
if !updated.IsEmailVerified() {
t.Fatal("expected email_verified=true")
}
if updated.EmailVerifyToken != "" {
t.Fatalf("expected email_verify_token cleared, got %q", updated.EmailVerifyToken)
}
}
func TestVerifyEmailMissingToken(t *testing.T) {
gin.SetMode(gin.TestMode)
setupTestDB(t)
r := gin.New()
r.GET("/api/v1/auth/verify-email", VerifyEmail)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestVerifyEmailInvalidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
setupTestDB(t)
r := gin.New()
r.GET("/api/v1/auth/verify-email", VerifyEmail)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token=missing", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestLoginRejectsUnverifiedUser(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
t.Setenv("JWT_SECRET", "test-secret-1234567890")
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
user := models.User{UserName: "u2", Email: "u2@example.com", Password: string(hashed), EmailVerified: boolPtr(false)}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
body, _ := json.Marshal(LoginRequest{Email: "u2@example.com", Password: "password123"})
r := gin.New()
r.POST("/api/v1/auth/login", Login)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
}
func TestLoginBadRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
setupTestDB(t)
r := gin.New()
r.POST("/api/v1/auth/login", Login)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewBufferString(`{"email":"bad"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestLoginInvalidCredentials(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
t.Setenv("JWT_SECRET", "test-secret-1234567890")
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
user := models.User{UserName: "u2x", Email: "u2x@example.com", Password: string(hashed), EmailVerified: boolPtr(true)}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
body, _ := json.Marshal(LoginRequest{Email: "u2x@example.com", Password: "wrong-pass"})
r := gin.New()
r.POST("/api/v1/auth/login", Login)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestLoginVerifiedUserSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
t.Setenv("JWT_SECRET", "test-secret-1234567890")
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
user := models.User{UserName: "u3", Email: "u3@example.com", Password: string(hashed), EmailVerified: boolPtr(true)}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
body, _ := json.Marshal(LoginRequest{Email: "u3@example.com", Password: "password123"})
r := gin.New()
r.POST("/api/v1/auth/login", Login)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestRefreshRejectsUnverifiedUser(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
t.Setenv("JWT_SECRET", "test-secret-1234567890")
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
user := models.User{UserName: "u4", Email: "u4@example.com", EmailVerified: boolPtr(false)}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
rt, err := jwtHelper.GenerateRefreshToken(user.ID, user.Email, user.UserName)
if err != nil {
t.Fatalf("failed to generate refresh token: %v", err)
}
body, _ := json.Marshal(RefreshRequest{RefreshToken: rt})
r := gin.New()
r.POST("/api/v1/auth/refresh", RefreshToken)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
}
func TestRefreshBadRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
setupTestDB(t)
r := gin.New()
r.POST("/api/v1/auth/refresh", RefreshToken)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewBufferString(`{"x":1}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestRefreshInvalidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
setupTestDB(t)
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
r := gin.New()
r.POST("/api/v1/auth/refresh", RefreshToken)
body := []byte(`{"refresh_token":"not-a-jwt"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestRefreshInvalidUser(t *testing.T) {
gin.SetMode(gin.TestMode)
setupTestDB(t)
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
rt, err := jwtHelper.GenerateRefreshToken(9999, "ghost@example.com", "ghost")
if err != nil {
t.Fatalf("failed to generate refresh token: %v", err)
}
body, _ := json.Marshal(RefreshRequest{RefreshToken: rt})
r := gin.New()
r.POST("/api/v1/auth/refresh", RefreshToken)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestRegisterPasswordMismatch(t *testing.T) {
gin.SetMode(gin.TestMode)
setupTestDB(t)
r := gin.New()
r.POST("/api/v1/auth/register", Register)
body := []byte(`{"username":"u","email":"u@example.com","password":"password123","confirm_password":"different123"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestRegisterDuplicateEmail(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
verified := true
hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
seed := models.User{UserName: "seed", Email: "dup@example.com", Password: string(hashed), EmailVerified: &verified}
if err := db.Create(&seed).Error; err != nil {
t.Fatalf("failed to seed user: %v", err)
}
r := gin.New()
r.POST("/api/v1/auth/register", Register)
body := []byte(`{"username":"newuser","email":"dup@example.com","password":"password123","confirm_password":"password123"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", w.Code)
}
}
func TestRegisterMailFailureRollsBackUser(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
t.Setenv("EMAIL_HOST", "")
t.Setenv("EMAIL_PORT", "")
t.Setenv("EMAIL_FROM", "")
r := gin.New()
r.POST("/api/v1/auth/register", Register)
body := []byte(`{"username":"rollback","email":"rollback@example.com","password":"password123","confirm_password":"password123"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", w.Code)
}
var count int64
if err := db.Model(&models.User{}).Where("email = ?", "rollback@example.com").Count(&count).Error; err != nil {
t.Fatalf("failed to count users: %v", err)
}
if count != 0 {
t.Fatalf("expected rollback delete, user count=%d", count)
}
}
func TestMeIncludesUsernameFallbackFromDB(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
user := models.User{UserName: "fallback-user", Email: "u5@example.com", EmailVerified: boolPtr(true)}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
c.Set("user_id", user.ID)
c.Set("email", user.Email)
Me(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["username"] != "fallback-user" {
t.Fatalf("expected username=fallback-user, got %v", resp["username"])
}
}
func TestMeUsesContextUsernameWhenProvided(t *testing.T) {
gin.SetMode(gin.TestMode)
setupTestDB(t)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
c.Set("user_id", uint(99))
c.Set("email", "ctx@example.com")
c.Set("username", "ctx-user")
Me(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["username"] != "ctx-user" {
t.Fatalf("expected username=ctx-user, got %v", resp["username"])
}
}
func TestGoogleLoginMissingConfig(t *testing.T) {
gin.SetMode(gin.TestMode)
setupTestDB(t)
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "")
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "")
t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "")
r := gin.New()
r.GET("/api/v1/auth/google/login", GoogleLogin)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/google/login", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
}
func TestGoogleCallbackInvalidState(t *testing.T) {
gin.SetMode(gin.TestMode)
setupTestDB(t)
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "client-id")
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "client-secret")
t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "http://localhost:8080/api/v1/auth/google/callback")
r := gin.New()
r.GET("/api/v1/auth/google/callback", GoogleCallback)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/google/callback?state=state1&code=code1", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestGoogleCallbackSuccessCreatesUserAndTokens(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "client-id")
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "client-secret")
t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "http://localhost:8080/api/v1/auth/google/callback")
t.Setenv("JWT_SECRET", "test-secret-1234567890")
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
originalExchange := exchangeGoogleCode
originalFetch := fetchGoogleUserInfo
t.Cleanup(func() {
exchangeGoogleCode = originalExchange
fetchGoogleUserInfo = originalFetch
})
exchangeGoogleCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) {
return &oauth2.Token{AccessToken: "google-access", TokenType: "Bearer"}, nil
}
fetchGoogleUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*googleUserInfo, error) {
return &googleUserInfo{
Sub: "google-sub-123",
Email: "google.user@example.com",
EmailVerified: true,
Name: "Google User",
Picture: "https://cdn.example.com/avatar.png",
GivenName: "Google",
FamilyName: "User",
}, nil
}
r := gin.New()
r.GET("/api/v1/auth/google/callback", GoogleCallback)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/google/callback?state=state123&code=code123", nil)
req.AddCookie(&http.Cookie{Name: googleOAuthStateCookieName, Value: "state123"})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["access_token"] == "" || resp["refresh_token"] == "" {
t.Fatal("expected access_token and refresh_token in response")
}
if resp["provider"] != "google" {
t.Fatalf("expected provider=google, got %v", resp["provider"])
}
var user models.User
if err := db.Where("email = ?", "google.user@example.com").First(&user).Error; err != nil {
t.Fatalf("expected user to be created, err=%v", err)
}
if !user.IsEmailVerified() {
t.Fatal("expected created google user to be verified")
}
var social models.SocialAccount
if err := db.Where("user_id = ? AND provider = ? AND provider_id = ?", user.ID, "google", "google-sub-123").First(&social).Error; err != nil {
t.Fatalf("expected social account to be linked, err=%v", err)
}
}
func TestGitHubLoginMissingConfig(t *testing.T) {
gin.SetMode(gin.TestMode)
setupTestDB(t)
t.Setenv("SOCIAL_AUTH_GITHUB_KEY", "")
t.Setenv("SOCIAL_AUTH_GITHUB_SECRET", "")
t.Setenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL", "")
r := gin.New()
r.GET("/api/v1/auth/github/login", GitHubLogin)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/github/login", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
}
func TestGitHubCallbackSuccessCreatesVerifiedUserAndTokens(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
t.Setenv("SOCIAL_AUTH_GITHUB_KEY", "gh-client-id")
t.Setenv("SOCIAL_AUTH_GITHUB_SECRET", "gh-client-secret")
t.Setenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL", "http://localhost:8080/api/v1/auth/github/callback")
t.Setenv("JWT_SECRET", "test-secret-1234567890")
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
originalExchange := exchangeGitHubCode
originalFetch := fetchGitHubUserInfo
t.Cleanup(func() {
exchangeGitHubCode = originalExchange
fetchGitHubUserInfo = originalFetch
})
exchangeGitHubCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) {
return &oauth2.Token{AccessToken: "github-access", TokenType: "Bearer"}, nil
}
fetchGitHubUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*socialUserProfile, error) {
return &socialUserProfile{
ProviderID: "4242",
Email: "github.user@example.com",
Name: "GitHub User",
AvatarURL: "https://cdn.example.com/gh-avatar.png",
EmailVerified: true,
}, nil
}
r := gin.New()
r.GET("/api/v1/auth/github/callback", GitHubCallback)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/github/callback?state=state-gh-123&code=code-gh-123", nil)
req.AddCookie(&http.Cookie{Name: githubOAuthStateCookieName, Value: "state-gh-123"})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["access_token"] == "" || resp["refresh_token"] == "" {
t.Fatal("expected access_token and refresh_token in response")
}
if resp["provider"] != "github" {
t.Fatalf("expected provider=github, got %v", resp["provider"])
}
var user models.User
if err := db.Where("email = ?", "github.user@example.com").First(&user).Error; err != nil {
t.Fatalf("expected user to be created, err=%v", err)
}
if !user.IsEmailVerified() {
t.Fatal("expected created github user to be verified")
}
var social models.SocialAccount
if err := db.Where("user_id = ? AND provider = ? AND provider_id = ?", user.ID, "github", "4242").First(&social).Error; err != nil {
t.Fatalf("expected github social account to be linked, err=%v", err)
}
}
func TestGoogleOAuthConfigSupportsSocialAuthEnv(t *testing.T) {
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "'google-client-id-from-social'")
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "'google-client-secret-from-social'")
t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "")
t.Setenv("APP_BASE_URL", "http://localhost:8080")
cfg, err := getGoogleOAuthConfig()
if err != nil {
t.Fatalf("expected config, got error: %v", err)
}
if cfg.ClientID != "google-client-id-from-social" {
t.Fatalf("unexpected client id: %q", cfg.ClientID)
}
if cfg.ClientSecret != "google-client-secret-from-social" {
t.Fatalf("unexpected client secret: %q", cfg.ClientSecret)
}
if cfg.RedirectURL != "http://localhost:8080/api/v1/auth/google/callback" {
t.Fatalf("unexpected redirect url: %q", cfg.RedirectURL)
}
}
func TestGitHubOAuthConfigSupportsSocialAuthEnv(t *testing.T) {
t.Setenv("SOCIAL_AUTH_GITHUB_KEY", "'github-client-id-from-social'")
t.Setenv("SOCIAL_AUTH_GITHUB_SECRET", "'github-client-secret-from-social'")
t.Setenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL", "")
t.Setenv("APP_BASE_URL", "http://localhost:8080")
cfg, err := getGitHubOAuthConfig()
if err != nil {
t.Fatalf("expected config, got error: %v", err)
}
if cfg.ClientID != "github-client-id-from-social" {
t.Fatalf("unexpected client id: %q", cfg.ClientID)
}
if cfg.ClientSecret != "github-client-secret-from-social" {
t.Fatalf("unexpected client secret: %q", cfg.ClientSecret)
}
if cfg.RedirectURL != "http://localhost:8080/api/v1/auth/github/callback" {
t.Fatalf("unexpected redirect url: %q", cfg.RedirectURL)
}
}