Files
a301_server/internal/auth/service_test.go
tolelom b0de89a18a
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 47s
Server CI/CD / deploy (push) Has been skipped
feat: 코드 리뷰 기반 전면 개선 — 보안, 검증, 테스트, 안정성
- 체인 nonce 경쟁 조건 수정 (operatorMu + per-user mutex)
- 등록/SSAFY 원자적 트랜잭션 (wallet+profile 롤백 보장)
- IdempotencyRequired 미들웨어 (SETNX 원자적 클레임)
- 런치 티켓 API (JWT URL 노출 방지)
- HttpOnly 쿠키 refresh token
- SSAFY OAuth state 파라미터 (CSRF 방지)
- Refresh 시 DB 조회로 최신 role 사용
- 공지사항/유저목록 페이지네이션
- BodyLimit 미들웨어 (1MB, upload 제외)
- 입력 검증 강화 (닉네임, 게임데이터, 공지 길이)
- 에러 메시지 내부 정보 노출 방지
- io.LimitReader (RPC 10MB, SSAFY 1MB)
- RequestID 비출력 문자 제거
- 단위 테스트 (auth 11, announcement 9, bossraid 16)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:03:25 +09:00

292 lines
8.1 KiB
Go

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