diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e..00000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## πŸ§ͺ Implementation Quest - -> μ§€μ •λœ **λ‹¨μœ„ ν…ŒμŠ€νŠΈ / 톡합 ν…ŒμŠ€νŠΈ / E2E ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€**λ₯Ό ν•„μˆ˜λ‘œ κ΅¬ν˜„ν•˜κ³ , λͺ¨λ“  ν…ŒμŠ€νŠΈλ₯Ό ν†΅κ³Όμ‹œν‚€λŠ” 것을 λͺ©ν‘œλ‘œ ν•©λ‹ˆλ‹€. - -### νšŒμ› κ°€μž… - -**🧱 λ‹¨μœ„ ν…ŒμŠ€νŠΈ** - -- [ ] ID κ°€ `영문 및 숫자 10자 이내` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. -- [ ] 이메일이 `xx@yy.zz` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. -- [ ] 생년월일이 `yyyy-MM-dd` ν˜•μ‹μ— λ§žμ§€ μ•ŠμœΌλ©΄, User 객체 생성에 μ‹€νŒ¨ν•œλ‹€. - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] νšŒμ› κ°€μž…μ‹œ User μ €μž₯이 μˆ˜ν–‰λœλ‹€. ( spy 검증 ) -- [ ] 이미 κ°€μž…λœ ID 둜 νšŒμ›κ°€μž… μ‹œλ„ μ‹œ, μ‹€νŒ¨ν•œλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] νšŒμ› κ°€μž…μ΄ 성곡할 경우, μƒμ„±λœ μœ μ € 정보λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] νšŒμ› κ°€μž… μ‹œμ— 성별이 없을 경우, `400 Bad Request` 응닡을 λ°˜ν™˜ν•œλ‹€. - -### λ‚΄ 정보 쑰회 - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•  경우, νšŒμ› 정보가 λ°˜ν™˜λœλ‹€. -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ 경우, null 이 λ°˜ν™˜λœλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] λ‚΄ 정보 μ‘°νšŒμ— 성곡할 경우, ν•΄λ‹Ήν•˜λŠ” μœ μ € 정보λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ID 둜 μ‘°νšŒν•  경우, `404 Not Found` 응닡을 λ°˜ν™˜ν•œλ‹€. - -### 포인트 쑰회 - -**πŸ”— 톡합 ν…ŒμŠ€νŠΈ** - -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•  경우, 보유 ν¬μΈνŠΈκ°€ λ°˜ν™˜λœλ‹€. -- [ ] ν•΄λ‹Ή ID 의 νšŒμ›μ΄ μ‘΄μž¬ν•˜μ§€ μ•Šμ„ 경우, null 이 λ°˜ν™˜λœλ‹€. - -**🌐 E2E ν…ŒμŠ€νŠΈ** - -- [ ] 포인트 μ‘°νšŒμ— 성곡할 경우, 보유 포인트λ₯Ό μ‘λ‹΅μœΌλ‘œ λ°˜ν™˜ν•œλ‹€. -- [ ] `X-USER-ID` 헀더가 없을 경우, `400 Bad Request` 응닡을 λ°˜ν™˜ν•œλ‹€. diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..9d7d11d8 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -9,6 +9,9 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") + + // security + implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") // querydsl diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 00000000..83ff27ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,29 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberService memberService; + + public MemberInfo register(String loginId, String rawPassword, String name, + LocalDate birthDate, String email) { + Member member = memberService.register(loginId, rawPassword, name, birthDate, email); + return MemberInfo.from(member); + } + + public MemberInfo getMe(Member authenticatedMember) { + return MemberInfo.fromWithMaskedName(authenticatedMember); + } + + public void changePassword(Member authenticatedMember, String currentPassword, String newPassword) { + memberService.changePassword(authenticatedMember, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java new file mode 100644 index 00000000..d6078e7a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,35 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; + +import java.time.LocalDate; + +public record MemberInfo(String loginId, String name, LocalDate birthDate, String email) { + public static MemberInfo from(Member member) { + return new MemberInfo( + member.getLoginId(), + member.getName(), + member.getBirthDate(), + member.getEmail() + ); + } + + public static MemberInfo fromWithMaskedName(Member member) { + return new MemberInfo( + member.getLoginId(), + maskName(member.getName()), + member.getBirthDate(), + member.getEmail() + ); + } + + private static String maskName(String name) { + if (name == null || name.isEmpty()) { + return ""; + } + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 00000000..70d1d50e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,142 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import java.time.LocalDate; + +@Entity +@Table(name = "member") +public class Member extends BaseEntity { + + @Column(unique = true, nullable = false) + private String loginId; + private String password; + private String name; + private LocalDate birthDate; + private String email; + + protected Member() {} + + private Member(String loginId, String password, String name, + LocalDate birthDate, String email) { + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public static Member create(String loginId, String rawPassword, + String name, LocalDate birthDate, + String email, PasswordEncoder encoder) { + validateNotBlank(loginId, "둜그인IDλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); + validateNotBlank(rawPassword, "λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); + validateNotBlank(name, "이름은 ν•„μˆ˜μž…λ‹ˆλ‹€."); + validateNotNull(birthDate, "생년월일은 ν•„μˆ˜μž…λ‹ˆλ‹€."); + validateNotBlank(email, "이메일은 ν•„μˆ˜μž…λ‹ˆλ‹€."); + + validateLoginId(loginId); + validatePassword(rawPassword, birthDate); + String normalizedName = normalizeName(name); + validateName(normalizedName); + validateEmail(email); + + String encodedPassword = encoder.encode(rawPassword); + return new Member(loginId, encodedPassword, normalizedName, birthDate, email); + } + + private static void validateNotNull(Object value, String message) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + private static void validateNotBlank(String value, String message) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + private static void validatePassword(String password, LocalDate birthDate) { + if (password.length() < 8 || password.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” 8~16μžμ—¬μ•Ό ν•©λ‹ˆλ‹€."); + } + if (!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” 영문 λŒ€μ†Œλ¬Έμž, 숫자, 특수문자만 ν—ˆμš©λ©λ‹ˆλ‹€."); + } + String birthDateStr = birthDate.toString().replace("-", ""); // 19900115 + if (password.contains(birthDateStr)) { + throw new CoreException(ErrorType.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜Έμ— 생년월일을 포함할 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + } + + private static void validateLoginId(String loginId) { + if (!loginId.matches("^[a-zA-Z0-9]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "둜그인IDλŠ” 영문과 숫자만 ν—ˆμš©λ©λ‹ˆλ‹€."); + } + } + + private static String normalizeName(String name) { + return name.trim().replaceAll("\\s+", " "); + } + + private static void validateName(String name) { + boolean isKorean = name.matches("^[κ°€-힣]+$"); + boolean isEnglish = name.matches("^[a-zA-Z]+( [a-zA-Z]+)*$"); + + if (!isKorean && !isEnglish) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 ν•œκΈ€λ§Œ λ˜λŠ” 영문만 ν—ˆμš©λ©λ‹ˆλ‹€."); + } + } + + private static void validateEmail(String email) { + if (!email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "μ˜¬λ°”λ₯Έ 이메일 ν˜•μ‹μ΄ μ•„λ‹™λ‹ˆλ‹€."); + } + } + + public void changePassword(String currentPassword, String newRawPassword, + PasswordEncoder encoder) { + validateNotBlank(currentPassword, "ν˜„μž¬ λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); + validateNotBlank(newRawPassword, "μƒˆ λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); + + // ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 확인 + if (!encoder.matches(currentPassword, this.password)) { + throw new CoreException(ErrorType.BAD_REQUEST, "ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + // μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ ν˜„μž¬μ™€ λ™μΌν•œμ§€ 확인 + if (encoder.matches(newRawPassword, this.password)) { + throw new CoreException(ErrorType.BAD_REQUEST, "μƒˆ λΉ„λ°€λ²ˆν˜ΈλŠ” ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ 달라야 ν•©λ‹ˆλ‹€."); + } + // μƒˆ λΉ„λ°€λ²ˆν˜Έ κ·œμΉ™ 검증 + validatePassword(newRawPassword, this.birthDate); + // λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ + this.password = encoder.encode(newRawPassword); + } + + // Getter + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public LocalDate getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java new file mode 100644 index 00000000..ef6b0660 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java @@ -0,0 +1,8 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberReader { + boolean existsByLoginId(String loginId); + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 00000000..39bd429e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.member; + +public interface MemberRepository { + Member save(Member member); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java new file mode 100644 index 00000000..7ab5d49a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,32 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberService { + + private final MemberReader memberReader; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + public Member register(String loginId, String rawPassword, String name, + LocalDate birthDate, String email) { + if (memberReader.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 μ‘΄μž¬ν•˜λŠ” 둜그인IDμž…λ‹ˆλ‹€."); + } + + Member member = Member.create(loginId, rawPassword, name, birthDate, email, passwordEncoder); + return memberRepository.save(member); + } + + public void changePassword(Member member, String currentPassword, String newPassword) { + member.changePassword(currentPassword, newPassword, passwordEncoder); + memberRepository.save(member); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.java new file mode 100644 index 00000000..a28f6d47 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/PasswordEncoder.java @@ -0,0 +1,6 @@ +package com.loopers.domain.member; + +public interface PasswordEncoder { + String encode(String rawPassword); + boolean matches(String rawPassword, String encodedPassword); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 00000000..ba8b089f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + boolean existsByLoginId(String loginId); + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java new file mode 100644 index 00000000..8dd8ce9c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberReader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class MemberReaderImpl implements MemberReader { + private final MemberJpaRepository memberJpaRepository; + + @Override + public boolean existsByLoginId(String loginId) { + return memberJpaRepository.existsByLoginId(loginId); + } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginId(loginId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 00000000..011b083d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + return memberJpaRepository.save(member); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/PasswordEncoderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/PasswordEncoderImpl.java new file mode 100644 index 00000000..e5275ed2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/PasswordEncoderImpl.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.PasswordEncoder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class PasswordEncoderImpl implements PasswordEncoder { + private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); + + @Override + public String encode(String rawPassword) { + return bCryptPasswordEncoder.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return bCryptPasswordEncoder.matches(rawPassword, encodedPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 00000000..daa8b42f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,68 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.application.member.MemberInfo; +import com.loopers.domain.member.Member; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller { + + private final MemberFacade memberFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse register( + @RequestBody MemberV1Dto.RegisterRequest request + ) { + MemberInfo info = memberFacade.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); + } + + @GetMapping("/me") + public ApiResponse getMe(HttpServletRequest request) { + Member authenticatedMember = getAuthenticatedMember(request); + MemberInfo info = memberFacade.getMe(authenticatedMember); + return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); + } + + @PatchMapping("/me/password") + public ApiResponse changePassword( + HttpServletRequest request, + @RequestBody MemberV1Dto.ChangePasswordRequest passwordRequest + ) { + Member authenticatedMember = getAuthenticatedMember(request); + memberFacade.changePassword( + authenticatedMember, + passwordRequest.currentPassword(), + passwordRequest.newPassword() + ); + return ApiResponse.success(null); + } + + private Member getAuthenticatedMember(HttpServletRequest request) { + Object attribute = request.getAttribute("authenticatedMember"); + if (attribute == null) { + throw new IllegalStateException("인증된 νšŒμ› 정보가 μ—†μŠ΅λ‹ˆλ‹€."); + } + return (Member) attribute; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 00000000..9536c4d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberInfo; + +import java.time.LocalDate; + +public class MemberV1Dto { + + public record RegisterRequest( + String loginId, + String password, + String name, + LocalDate birthDate, + String email + ) {} + + public record ChangePasswordRequest( + String currentPassword, + String newPassword + ) {} + + public record MemberResponse( + MemberDto member + ) { + public record MemberDto( + String loginId, + String name, + LocalDate birthDate, + String email + ) {} + + public static MemberResponse from(MemberInfo info) { + return new MemberResponse( + new MemberDto( + info.loginId(), + info.name(), + info.birthDate(), + info.email() + ) + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java new file mode 100644 index 00000000..a1e7cfb4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java @@ -0,0 +1,71 @@ +package com.loopers.support.auth; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberReader; +import com.loopers.domain.member.PasswordEncoder; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class MemberAuthFilter extends OncePerRequestFilter { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final MemberReader memberReader; + private final PasswordEncoder passwordEncoder; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + // 인증이 ν•„μš” μ—†λŠ” κ²½λ‘œλŠ” 톡과 + if (!requiresAuthentication(request)) { + filterChain.doFilter(request, response); + return; + } + + String loginId = request.getHeader(HEADER_LOGIN_ID); + String loginPw = request.getHeader(HEADER_LOGIN_PW); + + // 헀더가 μ—†μœΌλ©΄ 401 + if (loginId == null || loginPw == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + // νšŒμ› 쑰회 및 λΉ„λ°€λ²ˆν˜Έ 검증 + Member member = memberReader.findByLoginId(loginId) + .filter(m -> passwordEncoder.matches(loginPw, m.getPassword())) + .orElse(null); + + if (member == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + // 인증 성곡 - νšŒμ› 정보λ₯Ό request에 μ €μž₯ + request.setAttribute("authenticatedMember", member); + filterChain.doFilter(request, response); + } + + private boolean requiresAuthentication(HttpServletRequest request) { + String path = request.getRequestURI(); + String method = request.getMethod(); + + // POST /api/v1/members (νšŒμ›κ°€μž…)λŠ” 인증 λΆˆν•„μš” + if ("POST".equals(method) && "/api/v1/members".equals(path)) { + return false; + } + + // /api/v1/members/** κ²½λ‘œλŠ” 인증 ν•„μš” + return path.startsWith("/api/v1/members/"); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java new file mode 100644 index 00000000..57ea9a04 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java @@ -0,0 +1,27 @@ +package com.loopers.support.config; + +import com.loopers.support.auth.MemberAuthFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@RequiredArgsConstructor +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final MemberAuthFilter memberAuthFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .addFilterBefore(memberAuthFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java new file mode 100644 index 00000000..b24151b7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java @@ -0,0 +1,122 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MemberServiceTest { + + private MemberService memberService; + private FakeMemberReader fakeMemberReader; + private FakeMemberRepository fakeMemberRepository; + private StubPasswordEncoder stubPasswordEncoder; + + @BeforeEach + void setUp() { + fakeMemberReader = new FakeMemberReader(); + fakeMemberRepository = new FakeMemberRepository(); + stubPasswordEncoder = new StubPasswordEncoder(); + memberService = new MemberService(fakeMemberReader, fakeMemberRepository, stubPasswordEncoder); + } + + @DisplayName("νšŒμ›κ°€μž… μ‹œ, ") + @Nested + class Register { + + @DisplayName("이미 μ‘΄μž¬ν•˜λŠ” 둜그인ID둜 κ°€μž…ν•˜λ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenLoginIdAlreadyExists() { + // Arrange + String existingLoginId = "existingUser"; + fakeMemberReader.addExistingLoginId(existingLoginId); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.register( + existingLoginId, "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com" + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("μœ νš¨ν•œ μ •λ³΄λ‘œ κ°€μž…ν•˜λ©΄, νšŒμ›μ΄ μ €μž₯λœλ‹€.") + @Test + void savesMember_whenAllFieldsAreValid() { + // Arrange + String loginId = "newUser"; + String password = "Test1234!"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(1990, 1, 15); + String email = "test@example.com"; + + // Act + Member member = memberService.register(loginId, password, name, birthDate, email); + + // Assert + assertAll( + () -> assertThat(member.getLoginId()).isEqualTo(loginId), + () -> assertThat(member.getPassword()).isEqualTo("encoded_" + password), + () -> assertThat(member.getName()).isEqualTo(name), + () -> assertThat(member.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(member.getEmail()).isEqualTo(email) + ); + } + } + + // Fake κ΅¬ν˜„μ²΄ + static class FakeMemberReader implements MemberReader { + private final Map existingLoginIds = new HashMap<>(); + + void addExistingLoginId(String loginId) { + existingLoginIds.put(loginId, true); + } + + @Override + public boolean existsByLoginId(String loginId) { + return existingLoginIds.containsKey(loginId); + } + + @Override + public Optional findByLoginId(String loginId) { + return Optional.empty(); + } + } + + static class FakeMemberRepository implements MemberRepository { + private final Map members = new HashMap<>(); + private long idSequence = 1L; + + @Override + public Member save(Member member) { + members.put(idSequence++, member); + return member; + } + } + + static class StubPasswordEncoder implements PasswordEncoder { + @Override + public String encode(String rawPassword) { + return "encoded_" + rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encodedPassword.equals("encoded_" + rawPassword); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java new file mode 100644 index 00000000..12c0b652 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,441 @@ +package com.loopers.domain.member; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MemberTest { + + private final PasswordEncoder stubEncoder = new PasswordEncoder() { + @Override + public String encode(String rawPassword) { + return "encoded_" + rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encodedPassword.equals("encoded_" + rawPassword); + } + }; + + @DisplayName("ν•„μˆ˜κ°’ 검증 μ‹œ, ") + @Nested + class ValidateRequired { + + @DisplayName("둜그인IDκ°€ null이면, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenLoginIdIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create(null, "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("둜그인IDλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + + @DisplayName("둜그인IDκ°€ 빈 λ¬Έμžμ—΄μ΄λ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenLoginIdIsBlank() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create(" ", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("둜그인IDλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ null이면, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenPasswordIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create("testuser1", null, "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + + @DisplayName("이름이 null이면, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenNameIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create("testuser1", "Test1234!", null, + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("이름은 ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + + @DisplayName("생년월일이 null이면, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenBirthDateIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create("testuser1", "Test1234!", "홍길동", + null, "test@example.com", stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("생년월일은 ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + + @DisplayName("이메일이 null이면, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenEmailIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create("testuser1", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), null, stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("이메일은 ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + } + + @DisplayName("νšŒμ›μ„ 생성할 λ•Œ, ") + @Nested + class Create { + + @DisplayName("λͺ¨λ“  정보가 μœ νš¨ν•˜λ©΄, μ •μƒμ μœΌλ‘œ μƒμ„±λœλ‹€.") + @Test + void createsMember_whenAllFieldsAreValid() { + // Arrange + String loginId = "testuser1"; + String password = "Test1234!"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(1990, 1, 15); + String email = "test@example.com"; + + // Act + Member member = Member.create( + loginId, password, name, birthDate, email, stubEncoder + ); + + // Assert + assertAll( + () -> assertThat(member.getLoginId()).isEqualTo(loginId), + () -> assertThat(member.getPassword()).isEqualTo("encoded_" + password), // Stub이 λ°˜ν™˜ν•œ κ°’ + () -> assertThat(member.getName()).isEqualTo(name), + () -> assertThat(member.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(member.getEmail()).isEqualTo(email) + ); + } + } + + @DisplayName("둜그인ID 검증 μ‹œ, ") + @Nested + class ValidateLoginId { + + @DisplayName("영문과 숫자 μ™Έ λ¬Έμžκ°€ ν¬ν•¨λ˜λ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenLoginIdContainsSpecialCharacters() { + // Arrange + String invalidLoginId = "test@user"; + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + invalidLoginId, "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ 검증 μ‹œ, ") + @Nested + class ValidatePassword { + + @DisplayName("8자 미만이면, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenPasswordIsTooShort() { + // Arrange + String shortPassword = "Test12!"; // 7자 + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + "testuser1", shortPassword, "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("16자 초과이면, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenPasswordIsTooLong() { + // Arrange + String longPassword = "Test1234!Test1234"; // 17자 + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + "testuser1", longPassword, "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("ν—ˆμš©λ˜μ§€ μ•Šμ€ 문자(ν•œκΈ€)κ°€ ν¬ν•¨λ˜λ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenPasswordContainsInvalidCharacters() { + // Arrange + String invalidPassword = "Test123ν•œκΈ€!"; // ν•œκΈ€ 포함 + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + "testuser1", invalidPassword, "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일이 ν¬ν•¨λ˜λ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenPasswordContainsBirthDate() { + // Arrange + LocalDate birthDate = LocalDate.of(1990, 1, 15); + String passwordWithBirthDate = "Test19900115!"; // 생년월일 포함 + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + "testuser1", passwordWithBirthDate, "홍길동", + birthDate, "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이름 검증 μ‹œ, ") + @Nested + class ValidateName { + + @DisplayName("ν•œκΈ€κ³Ό 영문이 ν˜Όν•©λ˜λ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenNameContainsMixedLanguages() { + // Arrange + String mixedName = "Hong길동"; + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + "testuser1", "Test1234!", mixedName, + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("ν•œκΈ€ 이름에 곡백이 ν¬ν•¨λ˜λ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenKoreanNameContainsSpace() { + // Arrange + String koreanNameWithSpace = "홍 길동"; + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + "testuser1", "Test1234!", koreanNameWithSpace, + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("영문 μ΄λ¦„μ˜ 연속 곡백은 ν•˜λ‚˜λ‘œ μ •κ·œν™”λœλ‹€.") + @Test + void normalizesConsecutiveSpaces_whenEnglishNameHasMultipleSpaces() { + // Arrange + String nameWithConsecutiveSpaces = "John Doe"; + + // Act + Member member = Member.create( + "testuser1", "Test1234!", nameWithConsecutiveSpaces, + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + + // Assert + assertThat(member.getName()).isEqualTo("John Doe"); + } + } + + @DisplayName("이메일 검증 μ‹œ, ") + @Nested + class ValidateEmail { + + @DisplayName("μ˜¬λ°”λ₯΄μ§€ μ•Šμ€ ν˜•μ‹μ΄λ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenEmailFormatIsInvalid() { + // Arrange + String invalidEmail = "invalid-email"; + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + "testuser1", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), invalidEmail, stubEncoder + ); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ μ‹œ, ") + @Nested + class ChangePassword { + + private Member createMember() { + return Member.create( + "testuser1", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + ); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ null이면, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenCurrentPasswordIsNull() { + // Arrange + Member member = createMember(); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + member.changePassword(null, "NewPass5678!", stubEncoder); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("ν˜„μž¬ λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ null이면, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenNewPasswordIsNull() { + // Arrange + Member member = createMember(); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + member.changePassword("Test1234!", null, stubEncoder); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("μƒˆ λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμœΌλ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenCurrentPasswordDoesNotMatch() { + // Arrange + Member member = createMember(); + String wrongCurrentPassword = "WrongPass1!"; + String newPassword = "NewPass5678!"; + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + member.changePassword(wrongCurrentPassword, newPassword, stubEncoder); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•˜λ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenNewPasswordIsSameAsCurrent() { + // Arrange + Member member = createMember(); + String currentPassword = "Test1234!"; + String samePassword = "Test1234!"; + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + member.changePassword(currentPassword, samePassword, stubEncoder); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("μƒˆ λΉ„λ°€λ²ˆν˜ΈλŠ” ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ 달라야 ν•©λ‹ˆλ‹€."); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ κ·œμΉ™μ„ μœ„λ°˜ν•˜λ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenNewPasswordViolatesRules() { + // Arrange + Member member = createMember(); + String currentPassword = "Test1234!"; + String shortPassword = "short"; // 8자 미만 + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + member.changePassword(currentPassword, shortPassword, stubEncoder); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έμ— 생년월일이 ν¬ν•¨λ˜λ©΄, BAD_REQUEST μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void throwsBadRequest_whenNewPasswordContainsBirthDate() { + // Arrange + Member member = createMember(); + String currentPassword = "Test1234!"; + String passwordWithBirthDate = "Pass19900115!"; // 생년월일 포함 + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + member.changePassword(currentPassword, passwordWithBirthDate, stubEncoder); + }); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("λΉ„λ°€λ²ˆν˜Έμ— 생년월일을 포함할 수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + @DisplayName("λͺ¨λ“  쑰건이 μœ νš¨ν•˜λ©΄, λΉ„λ°€λ²ˆν˜Έκ°€ μ •μƒμ μœΌλ‘œ λ³€κ²½λœλ‹€.") + @Test + void changesPassword_whenAllConditionsAreValid() { + // Arrange + Member member = createMember(); + String currentPassword = "Test1234!"; + String newPassword = "NewPass5678!"; + + // Act + member.changePassword(currentPassword, newPassword, stubEncoder); + + // Assert + assertThat(member.getPassword()).isEqualTo("encoded_" + newPassword); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java new file mode 100644 index 00000000..b1a7bc99 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -0,0 +1,409 @@ +package com.loopers.interfaces.api; + +import com.loopers.infrastructure.member.MemberJpaRepository; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MemberV1ApiE2ETest { + + private static final String ENDPOINT_REGISTER = "/api/v1/members"; + private static final String ENDPOINT_ME = "/api/v1/members/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/members/me/password"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final TestRestTemplate testRestTemplate; + private final MemberJpaRepository memberJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberV1ApiE2ETest( + TestRestTemplate testRestTemplate, + MemberJpaRepository memberJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.memberJpaRepository = memberJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/members (νšŒμ›κ°€μž…)") + @Nested + class Register { + + @DisplayName("μœ νš¨ν•œ μ •λ³΄λ‘œ νšŒμ›κ°€μž…ν•˜λ©΄, 201 Created 응닡을 λ°›λŠ”λ‹€.") + @Test + void returnsCreated_whenValidRequest() { + // arrange + MemberV1Dto.RegisterRequest request = new MemberV1Dto.RegisterRequest( + "testUser1", + "Test1234!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(request), + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().member().loginId()).isEqualTo("testUser1"), + () -> assertThat(memberJpaRepository.existsByLoginId("testUser1")).isTrue() + ); + } + + @DisplayName("이미 μ‘΄μž¬ν•˜λŠ” 둜그인ID둜 κ°€μž…ν•˜λ©΄, 400 Bad Request 응닡을 λ°›λŠ”λ‹€.") + @Test + void returnsBadRequest_whenLoginIdAlreadyExists() { + // arrange - λ¨Όμ € νšŒμ›κ°€μž… + MemberV1Dto.RegisterRequest firstRequest = new MemberV1Dto.RegisterRequest( + "existingUser", + "Test1234!", + "홍길동", + LocalDate.of(1990, 1, 15), + "first@example.com" + ); + testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(firstRequest), + new ParameterizedTypeReference>() {} + ); + + // arrange - 같은 둜그인ID둜 λ‹€μ‹œ κ°€μž… μ‹œλ„ + MemberV1Dto.RegisterRequest duplicateRequest = new MemberV1Dto.RegisterRequest( + "existingUser", + "Test5678!", + "κΉ€μ² μˆ˜", + LocalDate.of(1985, 5, 20), + "second@example.com" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(duplicateRequest), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("잘λͺ»λœ 이메일 ν˜•μ‹μœΌλ‘œ κ°€μž…ν•˜λ©΄, 400 Bad Request 응닡을 λ°›λŠ”λ‹€.") + @Test + void returnsBadRequest_whenInvalidEmail() { + // arrange + MemberV1Dto.RegisterRequest request = new MemberV1Dto.RegisterRequest( + "testUser2", + "Test1234!", + "홍길동", + LocalDate.of(1990, 1, 15), + "invalid-email" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(request), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/members/me (λ‚΄ 정보 쑰회)") + @Nested + class GetMe { + + @DisplayName("μœ νš¨ν•œ 인증 ν—€λ”λ‘œ μ‘°νšŒν•˜λ©΄, 200 OK와 λ§ˆμŠ€ν‚Ήλœ 이름을 λ°˜ν™˜ν•œλ‹€.") + @Test + void returnsOk_whenValidAuth() { + // arrange - λ¨Όμ € νšŒμ›κ°€μž… + String loginId = "testUser1"; + String password = "Test1234!"; + MemberV1Dto.RegisterRequest registerRequest = new MemberV1Dto.RegisterRequest( + loginId, + password, + "홍길동", + LocalDate.of(1990, 1, 15), + "test@example.com" + ); + testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(registerRequest), + new ParameterizedTypeReference>() {} + ); + + // arrange - 인증 헀더 μ„€μ • + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(headers), + responseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().member().loginId()).isEqualTo(loginId), + () -> assertThat(response.getBody().data().member().name()).isEqualTo("홍길*"), // λ§ˆμŠ€ν‚Ή 확인 + () -> assertThat(response.getBody().data().member().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("인증 헀더가 μ—†μœΌλ©΄, 401 Unauthorized 응닡을 λ°›λŠ”λ‹€.") + @Test + void returnsUnauthorized_whenNoAuthHeader() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(null), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("잘λͺ»λœ λΉ„λ°€λ²ˆν˜Έλ‘œ μ‘°νšŒν•˜λ©΄, 401 Unauthorized 응닡을 λ°›λŠ”λ‹€.") + @Test + void returnsUnauthorized_whenWrongPassword() { + // arrange - λ¨Όμ € νšŒμ›κ°€μž… + String loginId = "testUser2"; + MemberV1Dto.RegisterRequest registerRequest = new MemberV1Dto.RegisterRequest( + loginId, + "Test1234!", + "홍길동", + LocalDate.of(1990, 1, 15), + "test2@example.com" + ); + testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(registerRequest), + new ParameterizedTypeReference>() {} + ); + + // arrange - 잘λͺ»λœ λΉ„λ°€λ²ˆν˜Έλ‘œ 인증 헀더 μ„€μ • + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, "WrongPassword1!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(headers), + responseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("PATCH /api/v1/members/me/password (λΉ„λ°€λ²ˆν˜Έ μˆ˜μ •)") + @Nested + class ChangePassword { + + @DisplayName("μœ νš¨ν•œ μš”μ²­μœΌλ‘œ λΉ„λ°€λ²ˆν˜Έλ₯Ό μˆ˜μ •ν•˜λ©΄, 200 OK 응닡을 λ°›λŠ”λ‹€.") + @Test + void returnsOk_whenValidRequest() { + // arrange - λ¨Όμ € νšŒμ›κ°€μž… + String loginId = "testUser1"; + String currentPassword = "Test1234!"; + String newPassword = "NewPass5678!"; + registerMember(loginId, currentPassword); + + // arrange - 인증 헀더와 μš”μ²­ λ³Έλ¬Έ μ„€μ • + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, currentPassword); + + MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( + currentPassword, + newPassword + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert - λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ 성곡 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // assert - μƒˆ λΉ„λ°€λ²ˆν˜Έλ‘œ 인증 κ°€λŠ₯ν•œμ§€ 확인 + HttpHeaders newAuthHeaders = new HttpHeaders(); + newAuthHeaders.set(HEADER_LOGIN_ID, loginId); + newAuthHeaders.set(HEADER_LOGIN_PW, newPassword); + + ResponseEntity> meResponse = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(newAuthHeaders), + new ParameterizedTypeReference<>() {} + ); + assertThat(meResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμœΌλ©΄, 400 Bad Request 응닡을 λ°›λŠ”λ‹€.") + @Test + void returnsBadRequest_whenCurrentPasswordNotMatch() { + // arrange - λ¨Όμ € νšŒμ›κ°€μž… + String loginId = "testUser2"; + String currentPassword = "Test1234!"; + registerMember(loginId, currentPassword); + + // arrange - 인증 헀더와 잘λͺ»λœ ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έλ‘œ μš”μ²­ + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, currentPassword); + + MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( + "WrongCurrent1!", // 잘λͺ»λœ ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ + "NewPass5678!" + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ λ™μΌν•˜λ©΄, 400 Bad Request 응닡을 λ°›λŠ”λ‹€.") + @Test + void returnsBadRequest_whenNewPasswordSameAsCurrent() { + // arrange - λ¨Όμ € νšŒμ›κ°€μž… + String loginId = "testUser3"; + String currentPassword = "Test1234!"; + registerMember(loginId, currentPassword); + + // arrange - 인증 헀더와 λ™μΌν•œ λΉ„λ°€λ²ˆν˜Έλ‘œ μš”μ²­ + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, currentPassword); + + MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( + currentPassword, + currentPassword // ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έμ™€ 동일 + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("μƒˆ λΉ„λ°€λ²ˆν˜Έκ°€ κ·œμΉ™μ„ μœ„λ°˜ν•˜λ©΄, 400 Bad Request 응닡을 λ°›λŠ”λ‹€.") + @Test + void returnsBadRequest_whenNewPasswordInvalid() { + // arrange - λ¨Όμ € νšŒμ›κ°€μž… + String loginId = "testUser4"; + String currentPassword = "Test1234!"; + registerMember(loginId, currentPassword); + + // arrange - 인증 헀더와 κ·œμΉ™ μœ„λ°˜ λΉ„λ°€λ²ˆν˜Έλ‘œ μš”μ²­ + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, currentPassword); + + MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( + currentPassword, + "short" // 8자 미만 + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + private void registerMember(String loginId, String password) { + MemberV1Dto.RegisterRequest registerRequest = new MemberV1Dto.RegisterRequest( + loginId, + password, + "홍길동", + LocalDate.of(1990, 1, 15), + loginId + "@example.com" + ); + testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(registerRequest), + new ParameterizedTypeReference>() {} + ); + } + } + +} diff --git a/http/commerce-api/member-v1.http b/http/commerce-api/member-v1.http new file mode 100644 index 00000000..d0371357 --- /dev/null +++ b/http/commerce-api/member-v1.http @@ -0,0 +1,114 @@ +@baseUrl = http://localhost:8080 + +### νšŒμ›κ°€μž… (정상) +POST {{baseUrl}}/api/v1/members +Content-Type: application/json + +{ + "loginId": "testUser1", + "password": "Test1234!", + "name": "홍길동", + "birthDate": "1990-01-15", + "email": "test@example.com" +} + +### νšŒμ›κ°€μž… (쀑볡 ID ν…ŒμŠ€νŠΈ - μœ„ μš”μ²­ λ¨Όμ € μ‹€ν–‰ ν›„) +POST {{baseUrl}}/api/v1/members +Content-Type: application/json + +{ + "loginId": "testUser1", + "password": "Test5678!", + "name": "κΉ€μ² μˆ˜", + "birthDate": "1985-05-20", + "email": "second@example.com" +} + +### νšŒμ›κ°€μž… (잘λͺ»λœ 이메일) +POST {{baseUrl}}/api/v1/members +Content-Type: application/json + +{ + "loginId": "testUser2", + "password": "Test1234!", + "name": "홍길동", + "birthDate": "1990-01-15", + "email": "invalid-email" +} + +### νšŒμ›κ°€μž… (잘λͺ»λœ λΉ„λ°€λ²ˆν˜Έ - λ„ˆλ¬΄ 짧음) +POST {{baseUrl}}/api/v1/members +Content-Type: application/json + +{ + "loginId": "testUser3", + "password": "short", + "name": "홍길동", + "birthDate": "1990-01-15", + "email": "test3@example.com" +} + +############################################### +# λ‚΄ 정보 쑰회 API +############################################### + +### λ‚΄ 정보 쑰회 (정상 - νšŒμ›κ°€μž… λ¨Όμ € μ‹€ν–‰ ν›„) +GET {{baseUrl}}/api/v1/members/me +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### λ‚΄ 정보 쑰회 (인증 헀더 μ—†μŒ - 401) +GET {{baseUrl}}/api/v1/members/me + +### λ‚΄ 정보 쑰회 (잘λͺ»λœ λΉ„λ°€λ²ˆν˜Έ - 401) +GET {{baseUrl}}/api/v1/members/me +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: WrongPassword1! + +############################################### +# λΉ„λ°€λ²ˆν˜Έ μˆ˜μ • API +############################################### + +### λΉ„λ°€λ²ˆν˜Έ μˆ˜μ • (정상 - νšŒμ›κ°€μž… λ¨Όμ € μ‹€ν–‰ ν›„) +PATCH {{baseUrl}}/api/v1/members/me/password +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "currentPassword": "Test1234!", + "newPassword": "NewPass5678!" +} + +### λΉ„λ°€λ²ˆν˜Έ μˆ˜μ • (ν˜„μž¬ λΉ„λ°€λ²ˆν˜Έ 뢈일치 - 400) +PATCH {{baseUrl}}/api/v1/members/me/password +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "currentPassword": "WrongCurrent1!", + "newPassword": "NewPass5678!" +} + +### λΉ„λ°€λ²ˆν˜Έ μˆ˜μ • (ν˜„μž¬μ™€ 동일 - 400) +PATCH {{baseUrl}}/api/v1/members/me/password +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "currentPassword": "Test1234!", + "newPassword": "Test1234!" +} + +### λΉ„λ°€λ²ˆν˜Έ μˆ˜μ • (κ·œμΉ™ μœ„λ°˜ - λ„ˆλ¬΄ 짧음 - 400) +PATCH {{baseUrl}}/api/v1/members/me/password +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "currentPassword": "Test1234!", + "newPassword": "short" +}