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