feat: 가입정보 변경·비밀번호 재인증 API + 백엔드 단위테스트·CI 게이트

- POST /auth/verify-password, PUT /auth/profile(세션 표시 이름 동기화)
- MemberMapper.updateProfile, AuthSessionMapper.updateName
- 단위테스트(24): CardNotificationParser(8), AuthService(16, Mockito)
- CI(.gitea): 테스트 리포트 아티팩트 업로드(clean build가 곧 테스트 게이트)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-06 14:08:01 +09:00
co-authored by Claude Opus 4.8
parent 5e2bdae37d
commit e592c174bf
11 changed files with 527 additions and 0 deletions
@@ -0,0 +1,90 @@
package com.sb.web.account.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 카드 결제 알림 파서 단위테스트.
* 핵심: 정상 결제 인식 / 취소 인식 / 광고성 푸시 차단 / 강신호 우선.
*/
class CardNotificationParserTest {
private final CardNotificationParser parser = new CardNotificationParser();
private final LocalDate today = LocalDate.of(2026, 6, 6);
@Test
@DisplayName("정상 승인 결제는 카드로 인식하고 카드사·금액을 추출한다")
void parsesApprovedPayment() {
var p = parser.parse("[Web발신]", "삼성카드 승인 50,000원 일시불\n스타벅스 06/06 14:30", today);
assertThat(p.isCard()).isTrue();
assertThat(p.isCanceled()).isFalse();
assertThat(p.getIssuer()).isEqualTo("삼성");
assertThat(p.getAmount()).isEqualTo(50000L);
assertThat(p.getNotifKey()).isNotBlank();
}
@Test
@DisplayName("승인취소는 취소 결제로 인식한다")
void parsesCanceledPayment() {
var p = parser.parse("현대카드", "승인취소 12,000원", today);
assertThat(p.isCard()).isTrue();
assertThat(p.isCanceled()).isTrue();
assertThat(p.getIssuer()).isEqualTo("현대");
assertThat(p.getAmount()).isEqualTo(12000L);
}
@Test
@DisplayName("강한 승인신호 없이 광고성 표현만 있으면 결제로 보지 않는다")
void blocksAdvertisementPush() {
var p = parser.parse("카카오", "결제하고 혜택 받기! 10,000원 쿠폰 적립 이벤트", today);
assertThat(p.isCard()).isFalse();
}
@Test
@DisplayName("승인 신호가 있으면 광고 표현이 섞여도 결제로 인식한다")
void strongSignalOverridesAdWords() {
var p = parser.parse("삼성카드", "승인 30,000원 (이벤트 적립 대상)", today);
assertThat(p.isCard()).isTrue();
assertThat(p.getAmount()).isEqualTo(30000L);
}
@Test
@DisplayName("금액이 없으면 결제 알림이 아니다")
void requiresAmount() {
var p = parser.parse("삼성카드", "승인 완료되었습니다", today);
assertThat(p.isCard()).isFalse();
}
@Test
@DisplayName("승인/취소 키워드가 없으면 결제 알림이 아니다")
void requiresApprovalKeyword() {
var p = parser.parse("삼성카드", "50,000원", today);
assertThat(p.isCard()).isFalse();
}
@Test
@DisplayName("빈 알림은 결제가 아니다")
void blankIsNotCard() {
assertThat(parser.parse(null, null, today).isCard()).isFalse();
assertThat(parser.parse("", "", today).isCard()).isFalse();
}
@Test
@DisplayName("MM/DD 날짜를 올해 거래일로 추정한다")
void guessesDate() {
var p = parser.parse("신한카드", "승인 5,000원 6/6", today);
assertThat(p.isCard()).isTrue();
assertThat(p.getDate()).isEqualTo(LocalDate.of(2026, 6, 6));
}
}
@@ -0,0 +1,294 @@
package com.sb.web.auth.service;
import com.sb.web.admin.service.AppSettingService;
import com.sb.web.auth.domain.AuthSession;
import com.sb.web.auth.domain.Member;
import com.sb.web.auth.dto.LoginRequest;
import com.sb.web.auth.dto.LoginResponse;
import com.sb.web.auth.dto.MemberResponse;
import com.sb.web.auth.dto.ProfileUpdateRequest;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.dto.SignupRequest;
import com.sb.web.auth.mapper.AuthSessionMapper;
import com.sb.web.auth.mapper.MemberMapper;
import com.sb.web.common.exception.ApiException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* AuthService 단위테스트 (Mockito).
* 인증 핵심: 비밀번호 재인증 / 가입정보 변경 / 회원가입 차단 / 로그인 / 세션 DB 백업 복원.
*/
@ExtendWith(MockitoExtension.class)
class AuthServiceTest {
@Mock MemberMapper memberMapper;
@Mock PasswordEncoder passwordEncoder;
@Mock RedisTemplate<String, Object> redisTemplate;
@Mock ValueOperations<String, Object> valueOps;
@Mock AppSettingService appSettingService;
@Mock AuthSessionMapper authSessionMapper;
AuthService service;
@BeforeEach
void setUp() {
service = new AuthService(memberMapper, passwordEncoder, redisTemplate, appSettingService, authSessionMapper);
}
private Member localMember() {
return Member.builder()
.id(1L).loginId("u1").password("hash").name("원래이름")
.email("old@b.com").provider("LOCAL").role("USER").status("ACTIVE")
.build();
}
// ===== verifyPassword =====
@Test
@DisplayName("verifyPassword: 일치하면 예외 없음")
void verifyPassword_ok() {
when(memberMapper.findById(1L)).thenReturn(localMember());
when(passwordEncoder.matches("pw", "hash")).thenReturn(true);
service.verifyPassword(1L, "pw");
}
@Test
@DisplayName("verifyPassword: 불일치면 401")
void verifyPassword_wrong() {
when(memberMapper.findById(1L)).thenReturn(localMember());
when(passwordEncoder.matches("bad", "hash")).thenReturn(false);
assertThatThrownBy(() -> service.verifyPassword(1L, "bad"))
.isInstanceOf(ApiException.class)
.extracting("status").isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("verifyPassword: 소셜 계정은 400")
void verifyPassword_social() {
Member social = Member.builder().id(1L).provider("NAVER").password(null).build();
when(memberMapper.findById(1L)).thenReturn(social);
assertThatThrownBy(() -> service.verifyPassword(1L, "pw"))
.isInstanceOf(ApiException.class)
.extracting("status").isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("verifyPassword: 회원 없으면 404")
void verifyPassword_notFound() {
when(memberMapper.findById(1L)).thenReturn(null);
assertThatThrownBy(() -> service.verifyPassword(1L, "pw"))
.isInstanceOf(ApiException.class)
.extracting("status").isEqualTo(HttpStatus.NOT_FOUND);
}
// ===== updateProfile =====
@Test
@DisplayName("updateProfile: 이름/이메일 변경 + 세션 이름 동기화")
void updateProfile_ok() {
when(memberMapper.findById(1L)).thenReturn(localMember());
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.get("session:tok")).thenReturn(null); // Redis 세션 없음 → DB만 동기화
ProfileUpdateRequest req = new ProfileUpdateRequest();
req.setName(" 새이름 ");
req.setEmail("new@b.com");
MemberResponse res = service.updateProfile(1L, "tok", req);
assertThat(res.getName()).isEqualTo("새이름"); // trim
assertThat(res.getEmail()).isEqualTo("new@b.com");
verify(memberMapper).updateProfile(1L, "새이름", "new@b.com");
verify(authSessionMapper).updateName("tok", "새이름");
}
@Test
@DisplayName("updateProfile: 이름이 비면 400, 변경 안 함")
void updateProfile_blankName() {
when(memberMapper.findById(1L)).thenReturn(localMember());
ProfileUpdateRequest req = new ProfileUpdateRequest();
req.setName(" ");
assertThatThrownBy(() -> service.updateProfile(1L, "tok", req))
.isInstanceOf(ApiException.class)
.extracting("status").isEqualTo(HttpStatus.BAD_REQUEST);
verify(memberMapper, never()).updateProfile(any(), any(), any());
}
// ===== signup =====
@Test
@DisplayName("signup: 가입 제한 시 403")
void signup_disabled() {
when(appSettingService.isSignupEnabled()).thenReturn(false);
assertThatThrownBy(() -> service.signup(new SignupRequest(), "1.2.3.4"))
.isInstanceOf(ApiException.class)
.extracting("status").isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
@DisplayName("signup: 허니팟(website) 채워지면 400")
void signup_honeypot() {
when(appSettingService.isSignupEnabled()).thenReturn(true);
SignupRequest req = new SignupRequest();
req.setWebsite("http://bot");
assertThatThrownBy(() -> service.signup(req, "1.2.3.4"))
.isInstanceOf(ApiException.class)
.extracting("status").isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("signup: IP 레이트리밋 초과 시 429")
void signup_rateLimited() {
when(appSettingService.isSignupEnabled()).thenReturn(true);
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.increment(anyString())).thenReturn(6L); // SIGNUP_LIMIT(5) 초과
assertThatThrownBy(() -> service.signup(new SignupRequest(), "1.2.3.4"))
.isInstanceOf(ApiException.class)
.extracting("status").isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
}
@Test
@DisplayName("signup: 정상 가입 시 회원 저장")
void signup_ok() {
when(appSettingService.isSignupEnabled()).thenReturn(true);
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.increment(anyString())).thenReturn(1L);
when(memberMapper.countByLoginId("newbie")).thenReturn(0);
when(passwordEncoder.encode("password1")).thenReturn("hashed");
SignupRequest req = new SignupRequest();
req.setLoginId("newbie");
req.setPassword("password1");
req.setName("새회원");
req.setEmail("n@b.com");
MemberResponse res = service.signup(req, "1.2.3.4");
assertThat(res.getLoginId()).isEqualTo("newbie");
verify(memberMapper).insert(any(Member.class));
}
// ===== login =====
@Test
@DisplayName("login: 비밀번호 불일치 시 401")
void login_wrongPassword() {
when(memberMapper.findByLoginId("u1")).thenReturn(localMember());
when(passwordEncoder.matches("bad", "hash")).thenReturn(false);
LoginRequest req = new LoginRequest();
req.setLoginId("u1");
req.setPassword("bad");
assertThatThrownBy(() -> service.login(req))
.isInstanceOf(ApiException.class)
.extracting("status").isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("login: 성공 시 토큰 발급 + Redis/DB 이중 저장")
void login_ok() {
when(memberMapper.findByLoginId("u1")).thenReturn(localMember());
when(passwordEncoder.matches("pw", "hash")).thenReturn(true);
when(redisTemplate.opsForValue()).thenReturn(valueOps);
LoginRequest req = new LoginRequest();
req.setLoginId("u1");
req.setPassword("pw");
req.setRememberMe(true);
LoginResponse res = service.login(req);
assertThat(res.getToken()).isNotBlank();
verify(valueOps).set(anyString(), any(), any()); // Redis 저장
verify(authSessionMapper).insert(any(AuthSession.class)); // DB 백업
}
// ===== getSession (DB 백업 복원) =====
@Test
@DisplayName("getSession: Redis 적중 시 그대로 반환")
void getSession_redisHit() {
when(redisTemplate.opsForValue()).thenReturn(valueOps);
SessionUser cached = SessionUser.builder().id(1L).loginId("u1").name("N").role("USER").rememberMe(false).build();
when(valueOps.get("session:tok")).thenReturn(cached);
SessionUser out = service.getSession("tok");
assertThat(out).isNotNull();
assertThat(out.getLoginId()).isEqualTo("u1");
}
@Test
@DisplayName("getSession: Redis 미스 + DB 유효 → 복원 및 재수화")
void getSession_dbFallback() {
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.get("session:tok")).thenReturn(null);
AuthSession db = AuthSession.builder()
.token("tok").memberId(1L).loginId("u1").name("N").role("USER")
.provider("LOCAL").rememberMe(true)
.expiresAt(LocalDateTime.now().plusDays(1))
.build();
when(authSessionMapper.findByToken("tok")).thenReturn(db);
SessionUser out = service.getSession("tok");
assertThat(out).isNotNull();
assertThat(out.getLoginId()).isEqualTo("u1");
verify(valueOps).set(eq("session:tok"), any(), any()); // Redis 재수화
verify(authSessionMapper).updateExpiry(eq("tok"), any()); // 슬라이딩 갱신
}
@Test
@DisplayName("getSession: Redis 미스 + DB 없음 → null")
void getSession_miss() {
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.get("session:tok")).thenReturn(null);
when(authSessionMapper.findByToken("tok")).thenReturn(null);
assertThat(service.getSession("tok")).isNull();
}
@Test
@DisplayName("getSession: DB 백업이 만료면 정리하고 null")
void getSession_dbExpired() {
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.get("session:tok")).thenReturn(null);
AuthSession expired = AuthSession.builder()
.token("tok").memberId(1L).expiresAt(LocalDateTime.now().minusMinutes(1))
.build();
when(authSessionMapper.findByToken("tok")).thenReturn(expired);
assertThat(service.getSession("tok")).isNull();
verify(authSessionMapper).delete("tok");
}
}