This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/auth_test.go
2026-02-23 01:57:28 +00:00

316 lines
7.8 KiB
Go

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"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
func testConfig() *config.Config {
return &config.Config{
JWTSecret: "test-secret-key-for-unit-tests",
}
}
// testAuthHandler creates an AuthHandler with nil stores (safe for non-DB tests).
func testAuthHandler() *AuthHandler {
return NewAuthHandler(testConfig(), store.Stores{})
}
// ── JWT Token Tests ─────────────────────────
func TestJWTGeneration(t *testing.T) {
cfg := testConfig()
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)
}
err = bcrypt.CompareHashAndPassword(hash, []byte(password))
if err != nil {
t.Error("Correct password should match hash")
}
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)")
}
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)
if hashToken(token) != hash {
t.Error("hashToken should be deterministic")
}
if hashToken("different") == hash {
t.Error("Different tokens should produce different hashes")
}
if len(hash) != 64 {
t.Errorf("Expected 64 char hex hash, got %d chars", len(hash))
}
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 := testAuthHandler()
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: "password too short",
body: `{"username":"test","email":"test@example.com","password":"short"}`,
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 := testAuthHandler()
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 := testAuthHandler()
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 := testAuthHandler()
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"])
}
}