Merge branch 'dev'
This commit is contained in:
@@ -47,11 +47,21 @@ jobs:
|
|||||||
java-version: '17'
|
java-version: '17'
|
||||||
cache: gradle
|
cache: gradle
|
||||||
|
|
||||||
|
# clean build 는 test 태스크를 포함 — 단위테스트 실패 시 빌드/배포 차단(게이트)
|
||||||
- name: Build & Test
|
- name: Build & Test
|
||||||
run: |
|
run: |
|
||||||
chmod +x ./gradlew
|
chmod +x ./gradlew
|
||||||
./gradlew --no-daemon clean build
|
./gradlew --no-daemon clean build
|
||||||
|
|
||||||
|
# 테스트 결과 리포트 — 성공/실패 무관하게 항상 업로드(실패 원인 확인용)
|
||||||
|
- name: Upload test report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: test-report
|
||||||
|
path: build/reports/tests/test
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload jar
|
- name: Upload jar
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -74,4 +74,22 @@ public class AuthController {
|
|||||||
authService.changePassword(current.getId(), req);
|
authService.changePassword(current.getId(), req);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 가입정보 변경 진입 전 비밀번호 재인증 */
|
||||||
|
@PostMapping("/verify-password")
|
||||||
|
public ResponseEntity<Void> verifyPassword(
|
||||||
|
@Valid @RequestBody com.sb.web.auth.dto.PasswordVerifyRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
|
authService.verifyPassword(current.getId(), req.getPassword());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 가입정보(이름/이메일) 변경 */
|
||||||
|
@PutMapping("/profile")
|
||||||
|
public MemberResponse updateProfile(
|
||||||
|
@Valid @RequestBody com.sb.web.auth.dto.ProfileUpdateRequest req,
|
||||||
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return authService.updateProfile(current.getId(), AuthInterceptor.resolveToken(request), req);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.sb.web.auth.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재인증 요청. 가입정보 변경 화면 진입 전 본인 확인용.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PasswordVerifyRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "비밀번호를 입력하세요.")
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.sb.web.auth.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가입정보(프로필) 변경 요청. 비밀번호 인증을 통과한 뒤 호출된다.
|
||||||
|
* 변경 대상: 이름, 이메일 (아이디/가입유형은 변경 불가).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ProfileUpdateRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "이름을 입력하세요.")
|
||||||
|
@Size(max = 100, message = "이름은 100자 이하여야 합니다.")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Email(message = "이메일 형식이 올바르지 않습니다.")
|
||||||
|
@Size(max = 255, message = "이메일은 255자 이하여야 합니다.")
|
||||||
|
private String email;
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ public interface AuthSessionMapper {
|
|||||||
|
|
||||||
int updateExpiry(@Param("token") String token, @Param("expiresAt") LocalDateTime expiresAt);
|
int updateExpiry(@Param("token") String token, @Param("expiresAt") LocalDateTime expiresAt);
|
||||||
|
|
||||||
|
/** 프로필 변경 시 백업 세션의 표시 이름 동기화 */
|
||||||
|
int updateName(@Param("token") String token, @Param("name") String name);
|
||||||
|
|
||||||
int delete(@Param("token") String token);
|
int delete(@Param("token") String token);
|
||||||
|
|
||||||
int deleteExpired(@Param("now") LocalDateTime now);
|
int deleteExpired(@Param("now") LocalDateTime now);
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ public interface MemberMapper {
|
|||||||
|
|
||||||
int updatePassword(@Param("id") Long id, @Param("password") String password);
|
int updatePassword(@Param("id") Long id, @Param("password") String password);
|
||||||
|
|
||||||
|
/** 프로필(이름/이메일) 변경 */
|
||||||
|
int updateProfile(@Param("id") Long id, @Param("name") String name, @Param("email") String email);
|
||||||
|
|
||||||
int updateRole(@Param("id") Long id, @Param("role") String role);
|
int updateRole(@Param("id") Long id, @Param("role") String role);
|
||||||
|
|
||||||
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
int updateStatus(@Param("id") Long id, @Param("status") String status);
|
||||||
|
|||||||
@@ -186,6 +186,71 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재인증 (가입정보 변경 진입 전 본인 확인). 일치하지 않으면 401.
|
||||||
|
*/
|
||||||
|
public void verifyPassword(Long memberId, String password) {
|
||||||
|
Member member = memberMapper.findById(memberId);
|
||||||
|
if (member == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
if (!"LOCAL".equals(member.getProvider()) || member.getPassword() == null) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "소셜 로그인 계정은 비밀번호 인증을 사용할 수 없습니다.");
|
||||||
|
}
|
||||||
|
if (password == null || !passwordEncoder.matches(password, member.getPassword())) {
|
||||||
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "비밀번호가 올바르지 않습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가입정보(이름/이메일) 변경. 비밀번호 인증을 통과한 화면에서 호출된다.
|
||||||
|
* 변경 후 세션(Redis + DB 백업)의 표시 이름도 동기화한다.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public MemberResponse updateProfile(Long memberId, String token, com.sb.web.auth.dto.ProfileUpdateRequest req) {
|
||||||
|
Member member = memberMapper.findById(memberId);
|
||||||
|
if (member == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
String name = req.getName() == null ? null : req.getName().trim();
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
throw new ApiException(HttpStatus.BAD_REQUEST, "이름을 입력하세요.");
|
||||||
|
}
|
||||||
|
String email = (req.getEmail() == null || req.getEmail().isBlank()) ? null : req.getEmail().trim();
|
||||||
|
memberMapper.updateProfile(memberId, name, email);
|
||||||
|
member.setName(name);
|
||||||
|
member.setEmail(email);
|
||||||
|
syncSessionName(token, name);
|
||||||
|
log.info("[profile] updated for {} (name={})", member.getLoginId(), name);
|
||||||
|
return MemberResponse.from(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 프로필 이름 변경 시 활성 세션(Redis + DB 백업)의 표시 이름을 맞춘다. */
|
||||||
|
private void syncSessionName(String token, String name) {
|
||||||
|
if (token == null || token.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String key = SESSION_PREFIX + token;
|
||||||
|
try {
|
||||||
|
Object cached = redisTemplate.opsForValue().get(key);
|
||||||
|
if (cached instanceof SessionUser u) {
|
||||||
|
u.setName(name);
|
||||||
|
Long ttl = redisTemplate.getExpire(key, java.util.concurrent.TimeUnit.SECONDS);
|
||||||
|
Duration remain = (ttl != null && ttl > 0)
|
||||||
|
? Duration.ofSeconds(ttl)
|
||||||
|
: (u.isRememberMe() ? REMEMBER_TTL : SESSION_TTL);
|
||||||
|
redisTemplate.opsForValue().set(key, u, remain);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[profile] Redis 세션 이름 동기화 실패(무시): {}", e.toString());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
authSessionMapper.updateName(token, name);
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// DB 백업 동기화 실패해도 다음 로그인 시 갱신됨
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 비밀번호 변경 (본인). 현재 비밀번호 검증 후 새 비밀번호로 교체.
|
* 비밀번호 변경 (본인). 현재 비밀번호 검증 후 새 비밀번호로 교체.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
UPDATE auth_session SET expires_at = #{expiresAt} WHERE token = #{token}
|
UPDATE auth_session SET expires_at = #{expiresAt} WHERE token = #{token}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="updateName">
|
||||||
|
UPDATE auth_session SET name = #{name} WHERE token = #{token}
|
||||||
|
</update>
|
||||||
|
|
||||||
<delete id="delete">
|
<delete id="delete">
|
||||||
DELETE FROM auth_session WHERE token = #{token}
|
DELETE FROM auth_session WHERE token = #{token}
|
||||||
</delete>
|
</delete>
|
||||||
|
|||||||
@@ -54,6 +54,10 @@
|
|||||||
UPDATE member SET password = #{password}, updated_at = NOW() WHERE id = #{id}
|
UPDATE member SET password = #{password}, updated_at = NOW() WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="updateProfile">
|
||||||
|
UPDATE member SET name = #{name}, email = #{email}, updated_at = NOW() WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
<update id="updateRole">
|
<update id="updateRole">
|
||||||
UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id}
|
UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user