Issue 4 jwt (#24)
This commit is contained in:
323
server/handlers/auth.go
Normal file
323
server/handlers/auth.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
const (
|
||||
accessTokenDuration = 15 * time.Minute
|
||||
refreshTokenDuration = 7 * 24 * time.Hour
|
||||
bcryptCost = 12
|
||||
)
|
||||
|
||||
// Claims is the JWT access token payload.
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// ── Request / Response types ────────────────
|
||||
|
||||
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,max=128"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Login string `json:"login" binding:"required"` // email or username
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"` // seconds
|
||||
User userResponse `json:"user"`
|
||||
}
|
||||
|
||||
type userResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// AuthHandler holds dependencies for auth endpoints.
|
||||
type AuthHandler struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler.
|
||||
func NewAuthHandler(cfg *config.Config) *AuthHandler {
|
||||
return &AuthHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
// ── Register ────────────────────────────────
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req registerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
|
||||
// Hash password
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Insert user
|
||||
var user userResponse
|
||||
err = database.DB.QueryRow(`
|
||||
INSERT INTO users (username, email, password_hash, role)
|
||||
VALUES ($1, $2, $3, 'user')
|
||||
RETURNING id, username, email, display_name, role
|
||||
`, req.Username, req.Email, string(hash)).Scan(
|
||||
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
field := "email"
|
||||
if strings.Contains(err.Error(), "username") {
|
||||
field = "username"
|
||||
}
|
||||
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%s already taken", field)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
resp, err := h.generateTokenPair(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// ── Login ───────────────────────────────────
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
req.Login = strings.TrimSpace(req.Login)
|
||||
|
||||
// Look up user by email or username
|
||||
var user userResponse
|
||||
var passwordHash string
|
||||
var isActive bool
|
||||
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT id, username, email, display_name, role, password_hash, is_active
|
||||
FROM users
|
||||
WHERE email = $1 OR username = $1
|
||||
`, req.Login).Scan(
|
||||
&user.ID, &user.Username, &user.Email, &user.DisplayName,
|
||||
&user.Role, &passwordHash, &isActive,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"})
|
||||
return
|
||||
}
|
||||
|
||||
if !isActive {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "account disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update last_login_at
|
||||
_, _ = database.DB.Exec(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, user.ID)
|
||||
|
||||
// Generate tokens
|
||||
resp, err := h.generateTokenPair(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ── Refresh ─────────────────────────────────
|
||||
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
var req refreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tokenHash := hashToken(req.RefreshToken)
|
||||
|
||||
// Find and validate the refresh token
|
||||
var tokenID, userID string
|
||||
var expiresAt time.Time
|
||||
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT rt.id, rt.user_id, rt.expires_at
|
||||
FROM refresh_tokens rt
|
||||
WHERE rt.token_hash = $1 AND rt.revoked_at IS NULL
|
||||
`, tokenHash).Scan(&tokenID, &userID, &expiresAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token validation failed"})
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(expiresAt) {
|
||||
// Revoke expired token
|
||||
_, _ = database.DB.Exec(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, tokenID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token expired"})
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke the old token (rotation)
|
||||
_, _ = database.DB.Exec(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, tokenID)
|
||||
|
||||
// Look up user
|
||||
var user userResponse
|
||||
var isActive bool
|
||||
err = database.DB.QueryRow(`
|
||||
SELECT id, username, email, display_name, role, is_active
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(
|
||||
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &isActive,
|
||||
)
|
||||
if err != nil || !isActive {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "account unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
// Issue new pair
|
||||
resp, err := h.generateTokenPair(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ── Logout ──────────────────────────────────
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
var req refreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// No refresh token provided — just acknowledge
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
return
|
||||
}
|
||||
|
||||
tokenHash := hashToken(req.RefreshToken)
|
||||
|
||||
// Revoke the refresh token
|
||||
_, _ = database.DB.Exec(`
|
||||
UPDATE refresh_tokens SET revoked_at = NOW()
|
||||
WHERE token_hash = $1 AND revoked_at IS NULL
|
||||
`, tokenHash)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
// ── Token Generation ────────────────────────
|
||||
|
||||
func (h *AuthHandler) generateTokenPair(user userResponse) (*authResponse, error) {
|
||||
now := time.Now()
|
||||
|
||||
// Access token (JWT)
|
||||
claims := Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenDuration)),
|
||||
Issuer: "chat-switchboard",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
accessToken, err := token.SignedString([]byte(h.cfg.JWTSecret))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign access token: %w", err)
|
||||
}
|
||||
|
||||
// Refresh token (opaque random string, stored hashed)
|
||||
refreshBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(refreshBytes); err != nil {
|
||||
return nil, fmt.Errorf("generate refresh token: %w", err)
|
||||
}
|
||||
refreshToken := hex.EncodeToString(refreshBytes)
|
||||
refreshHash := hashToken(refreshToken)
|
||||
|
||||
// Store refresh token
|
||||
_, err = database.DB.Exec(`
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`, user.ID, refreshHash, now.Add(refreshTokenDuration))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &authResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int(accessTokenDuration.Seconds()),
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// hashToken returns a SHA-256 hex digest of a token string.
|
||||
// Refresh tokens are stored hashed so a DB leak doesn't
|
||||
// compromise active sessions.
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
331
server/handlers/auth_test.go
Normal file
331
server/handlers/auth_test.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func testConfig() *config.Config {
|
||||
return &config.Config{
|
||||
JWTSecret: "test-secret-key-for-unit-tests",
|
||||
}
|
||||
}
|
||||
|
||||
// ── JWT Token Tests ─────────────────────────
|
||||
|
||||
func TestJWTGeneration(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
|
||||
// Create a token the same way the handler does
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
UserID: "550e8400-e29b-41d4-a716-446655440000",
|
||||
Email: "test@example.com",
|
||||
Role: "user",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
|
||||
Issuer: "chat-switchboard",
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(cfg.JWTSecret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to sign token: %v", err)
|
||||
}
|
||||
|
||||
if tokenString == "" {
|
||||
t.Fatal("Token string is empty")
|
||||
}
|
||||
|
||||
// Parse it back
|
||||
parsed := &Claims{}
|
||||
parsedToken, err := jwt.ParseWithClaims(tokenString, parsed, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(cfg.JWTSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse token: %v", err)
|
||||
}
|
||||
if !parsedToken.Valid {
|
||||
t.Fatal("Parsed token is not valid")
|
||||
}
|
||||
if parsed.UserID != claims.UserID {
|
||||
t.Errorf("UserID mismatch: got %s, want %s", parsed.UserID, claims.UserID)
|
||||
}
|
||||
if parsed.Email != claims.Email {
|
||||
t.Errorf("Email mismatch: got %s, want %s", parsed.Email, claims.Email)
|
||||
}
|
||||
if parsed.Role != claims.Role {
|
||||
t.Errorf("Role mismatch: got %s, want %s", parsed.Role, claims.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTExpired(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
|
||||
claims := Claims{
|
||||
UserID: "test-user",
|
||||
Email: "test@example.com",
|
||||
Role: "user",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-30 * time.Minute)),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, _ := token.SignedString([]byte(cfg.JWTSecret))
|
||||
|
||||
parsed := &Claims{}
|
||||
_, err := jwt.ParseWithClaims(tokenString, parsed, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(cfg.JWTSecret), nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for expired token, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTWrongSecret(t *testing.T) {
|
||||
claims := Claims{
|
||||
UserID: "test-user",
|
||||
Email: "test@example.com",
|
||||
Role: "user",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, _ := token.SignedString([]byte("correct-secret"))
|
||||
|
||||
parsed := &Claims{}
|
||||
_, err := jwt.ParseWithClaims(tokenString, parsed, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte("wrong-secret"), nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for wrong secret, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Password Hashing Tests ─────────────────
|
||||
|
||||
func TestBcryptHash(t *testing.T) {
|
||||
password := "mySecurePassword123!"
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to hash: %v", err)
|
||||
}
|
||||
|
||||
// Correct password should match
|
||||
err = bcrypt.CompareHashAndPassword(hash, []byte(password))
|
||||
if err != nil {
|
||||
t.Error("Correct password should match hash")
|
||||
}
|
||||
|
||||
// Wrong password should not match
|
||||
err = bcrypt.CompareHashAndPassword(hash, []byte("wrongPassword"))
|
||||
if err == nil {
|
||||
t.Error("Wrong password should not match hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBcryptDifferentHashesForSamePassword(t *testing.T) {
|
||||
password := "samePassword"
|
||||
|
||||
hash1, _ := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
hash2, _ := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
|
||||
if string(hash1) == string(hash2) {
|
||||
t.Error("Same password should produce different hashes (salt)")
|
||||
}
|
||||
|
||||
// Both should still verify
|
||||
if bcrypt.CompareHashAndPassword(hash1, []byte(password)) != nil {
|
||||
t.Error("hash1 should verify")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword(hash2, []byte(password)) != nil {
|
||||
t.Error("hash2 should verify")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Token Hashing Tests ────────────────────
|
||||
|
||||
func TestTokenHash(t *testing.T) {
|
||||
token := "abc123refreshtoken"
|
||||
hash := hashToken(token)
|
||||
|
||||
// Should be deterministic
|
||||
if hashToken(token) != hash {
|
||||
t.Error("hashToken should be deterministic")
|
||||
}
|
||||
|
||||
// Different token should produce different hash
|
||||
if hashToken("different") == hash {
|
||||
t.Error("Different tokens should produce different hashes")
|
||||
}
|
||||
|
||||
// Should be hex-encoded SHA-256 (64 chars)
|
||||
if len(hash) != 64 {
|
||||
t.Errorf("Expected 64 char hex hash, got %d chars", len(hash))
|
||||
}
|
||||
|
||||
// Verify it's actually SHA-256
|
||||
expected := sha256.Sum256([]byte(token))
|
||||
expectedHex := hex.EncodeToString(expected[:])
|
||||
if hash != expectedHex {
|
||||
t.Errorf("Hash mismatch: got %s, want %s", hash, expectedHex)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request Validation Tests ────────────────
|
||||
|
||||
func TestRegisterValidation(t *testing.T) {
|
||||
h := NewAuthHandler(testConfig())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "missing fields",
|
||||
body: `{}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "missing password",
|
||||
body: `{"username":"test","email":"test@example.com"}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid email",
|
||||
body: `{"username":"test","email":"notanemail","password":"12345678"}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "password too short",
|
||||
body: `{"username":"test","email":"test@example.com","password":"short"}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "username too short",
|
||||
body: `{"username":"ab","email":"test@example.com","password":"12345678"}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/auth/register",
|
||||
strings.NewReader(tt.body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Register(c)
|
||||
|
||||
if w.Code != tt.wantCode {
|
||||
t.Errorf("got status %d, want %d. Body: %s",
|
||||
w.Code, tt.wantCode, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginValidation(t *testing.T) {
|
||||
h := NewAuthHandler(testConfig())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "missing fields",
|
||||
body: `{}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "missing password",
|
||||
body: `{"login":"test@example.com"}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "missing login",
|
||||
body: `{"password":"12345678"}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/auth/login",
|
||||
strings.NewReader(tt.body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Login(c)
|
||||
|
||||
if w.Code != tt.wantCode {
|
||||
t.Errorf("got status %d, want %d", w.Code, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshValidation(t *testing.T) {
|
||||
h := NewAuthHandler(testConfig())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/auth/refresh",
|
||||
strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Refresh(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for missing refresh_token, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutWithoutToken(t *testing.T) {
|
||||
h := NewAuthHandler(testConfig())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/auth/logout",
|
||||
strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Logout(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Logout without token should succeed, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["message"] != "logged out" {
|
||||
t.Errorf("Expected 'logged out' message, got %s", resp["message"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user