package auth import ( "testing" "time" "a301_server/pkg/config" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" ) // --------------------------------------------------------------------------- // 1. Password hashing (bcrypt) // --------------------------------------------------------------------------- func TestBcryptHashAndVerify(t *testing.T) { tests := []struct { name string password string wantMatch bool }{ {"short password", "abc", true}, {"normal password", "myP@ssw0rd!", true}, {"unicode password", "비밀번호123", true}, {"empty password", "", true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { hash, err := bcrypt.GenerateFromPassword([]byte(tc.password), bcrypt.DefaultCost) if err != nil { t.Fatalf("GenerateFromPassword failed: %v", err) } err = bcrypt.CompareHashAndPassword(hash, []byte(tc.password)) if (err == nil) != tc.wantMatch { t.Errorf("CompareHashAndPassword: got err=%v, wantMatch=%v", err, tc.wantMatch) } }) } } func TestBcryptWrongPassword(t *testing.T) { hash, err := bcrypt.GenerateFromPassword([]byte("correct"), bcrypt.DefaultCost) if err != nil { t.Fatalf("GenerateFromPassword failed: %v", err) } if err := bcrypt.CompareHashAndPassword(hash, []byte("wrong")); err == nil { t.Error("expected error comparing wrong password, got nil") } } func TestBcryptDifferentHashesForSamePassword(t *testing.T) { password := "samePassword" hash1, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hash2, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if string(hash1) == string(hash2) { t.Error("expected different hashes for the same password (different salts)") } } // --------------------------------------------------------------------------- // 2. JWT token generation and parsing // --------------------------------------------------------------------------- func setupTestConfig() { config.C = config.Config{ JWTSecret: "test-jwt-secret-key-for-unit-tests", RefreshSecret: "test-refresh-secret-key-for-unit-tests", JWTExpiryHours: 1, } } func TestIssueAndParseAccessToken(t *testing.T) { setupTestConfig() tests := []struct { name string userID uint username string role string }{ {"admin user", 1, "admin", "admin"}, {"regular user", 42, "player1", "user"}, {"unicode username", 100, "유저", "user"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { expiry := time.Duration(config.C.JWTExpiryHours) * time.Hour claims := &Claims{ UserID: tc.userID, Username: tc.username, Role: tc.role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenStr, err := token.SignedString([]byte(config.C.JWTSecret)) if err != nil { t.Fatalf("SignedString failed: %v", err) } parsed, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) { return []byte(config.C.JWTSecret), nil }) if err != nil { t.Fatalf("ParseWithClaims failed: %v", err) } if !parsed.Valid { t.Fatal("parsed token is not valid") } got, ok := parsed.Claims.(*Claims) if !ok { t.Fatal("failed to cast claims") } if got.UserID != tc.userID { t.Errorf("UserID = %d, want %d", got.UserID, tc.userID) } if got.Username != tc.username { t.Errorf("Username = %q, want %q", got.Username, tc.username) } if got.Role != tc.role { t.Errorf("Role = %q, want %q", got.Role, tc.role) } }) } } func TestParseTokenWithWrongSecret(t *testing.T) { setupTestConfig() claims := &Claims{ UserID: 1, Username: "test", Role: "user", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenStr, _ := token.SignedString([]byte(config.C.JWTSecret)) _, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) { return []byte("wrong-secret"), nil }) if err == nil { t.Error("expected error parsing token with wrong secret, got nil") } } func TestParseExpiredToken(t *testing.T) { setupTestConfig() claims := &Claims{ UserID: 1, Username: "test", Role: "user", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenStr, _ := token.SignedString([]byte(config.C.JWTSecret)) _, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) { return []byte(config.C.JWTSecret), nil }) if err == nil { t.Error("expected error parsing expired token, got nil") } } func TestRefreshTokenUsesDifferentSecret(t *testing.T) { setupTestConfig() claims := &Claims{ UserID: 1, Username: "test", Role: "user", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenExpiry)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } // Sign with refresh secret token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenStr, _ := token.SignedString([]byte(config.C.RefreshSecret)) // Should fail with JWT secret _, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) { return []byte(config.C.JWTSecret), nil }) if err == nil { t.Error("expected error parsing refresh token with access secret") } // Should succeed with refresh secret parsed, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) { return []byte(config.C.RefreshSecret), nil }) if err != nil { t.Fatalf("expected success with refresh secret, got: %v", err) } if !parsed.Valid { t.Error("parsed refresh token is not valid") } } // --------------------------------------------------------------------------- // 3. Input validation helpers (sanitizeForUsername) // --------------------------------------------------------------------------- func TestSanitizeForUsername(t *testing.T) { tests := []struct { name string input string want string }{ {"lowercase letters", "hello", "hello"}, {"uppercase converted", "HeLLo", "hello"}, {"digits kept", "user123", "user123"}, {"underscore kept", "user_name", "user_name"}, {"hyphen kept", "user-name", "user-name"}, {"special chars removed", "user@name!#$", "username"}, {"spaces removed", "user name", "username"}, {"unicode removed", "유저abc", "abc"}, {"mixed", "User-123_Test!", "user-123_test"}, {"empty input", "", ""}, {"all removed", "!!@@##", ""}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := sanitizeForUsername(tc.input) if got != tc.want { t.Errorf("sanitizeForUsername(%q) = %q, want %q", tc.input, got, tc.want) } }) } } // --------------------------------------------------------------------------- // 4. Claims struct fields // --------------------------------------------------------------------------- func TestClaimsRoundTrip(t *testing.T) { setupTestConfig() original := &Claims{ UserID: 999, Username: "testuser", Role: "admin", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, original) tokenStr, err := token.SignedString([]byte(config.C.JWTSecret)) if err != nil { t.Fatalf("signing failed: %v", err) } parsed, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) { return []byte(config.C.JWTSecret), nil }) if err != nil { t.Fatalf("parsing failed: %v", err) } got := parsed.Claims.(*Claims) if got.UserID != original.UserID { t.Errorf("UserID: got %d, want %d", got.UserID, original.UserID) } if got.Username != original.Username { t.Errorf("Username: got %q, want %q", got.Username, original.Username) } if got.Role != original.Role { t.Errorf("Role: got %q, want %q", got.Role, original.Role) } }