From 0ef23be2409b31808aaff926d85e83b43da33515 Mon Sep 17 00:00:00 2001 From: DH CHOI Date: Mon, 7 Mar 2022 20:20:44 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Fix:=20=EC=BD=94=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=9C=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EC=A0=95=20API=20HTTP=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post로 되어있었으나, Put으로 변경하는게 RESTful하다고 생각되어 변경 --- .../cloneproject/Instagram/controller/MemberAuthController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/cloneproject/Instagram/controller/MemberAuthController.java b/src/main/java/cloneproject/Instagram/controller/MemberAuthController.java index 7ea07734..6120bfe2 100644 --- a/src/main/java/cloneproject/Instagram/controller/MemberAuthController.java +++ b/src/main/java/cloneproject/Instagram/controller/MemberAuthController.java @@ -175,7 +175,7 @@ public ResponseEntity sendResetPasswordCode( @ApiOperation(value = "코드를 통한 비밀번호 재설정") @ApiImplicitParam(name = "Authorization", value = "불필요", required = false, example = " ") - @PostMapping(value = "/accounts/password/reset") + @PutMapping(value = "/accounts/password/reset") public ResponseEntity resetPassword(@Validated @RequestBody ResetPasswordRequest resetPasswordRequest, HttpServletResponse response) { JwtDto jwt = memberAuthService.resetPassword(resetPasswordRequest); From e1be95456e8b73cb46996c2d3c9657e338429aee Mon Sep 17 00:00:00 2001 From: DH CHOI Date: Mon, 7 Mar 2022 20:21:26 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Test:=20=EB=A9=A4=EB=B2=84=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberAuthController 테스트 추가 작성 - 비밀번호 재설정 인증코드와 관련된 API 추가 작성 - MemberAuthService 테스트 작성(미완) - AuthenticationManagerBuilder관련 문제로 로그인 관련 API는 다음 PR때 테스트 작성 --- .../controller/MemberAuthControllerTest.java | 139 +++++++++++- .../service/MemberAuthServiceTest.java | 205 ++++++++++++++++++ 2 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 src/test/java/cloneproject/Instagram/service/MemberAuthServiceTest.java diff --git a/src/test/java/cloneproject/Instagram/controller/MemberAuthControllerTest.java b/src/test/java/cloneproject/Instagram/controller/MemberAuthControllerTest.java index 064fc8c1..6c227e2f 100644 --- a/src/test/java/cloneproject/Instagram/controller/MemberAuthControllerTest.java +++ b/src/test/java/cloneproject/Instagram/controller/MemberAuthControllerTest.java @@ -17,6 +17,7 @@ import cloneproject.Instagram.dto.member.JwtDto; import cloneproject.Instagram.dto.member.LoginRequest; import cloneproject.Instagram.dto.member.RegisterRequest; +import cloneproject.Instagram.dto.member.ResetPasswordRequest; import cloneproject.Instagram.dto.member.SendConfirmationEmailRequest; import cloneproject.Instagram.dto.member.UpdatePasswordRequest; import cloneproject.Instagram.dto.result.ResultCode; @@ -50,7 +51,7 @@ public class MemberAuthControllerTest { private ObjectMapper objectMapper; private MockMvc mockMvc; - + private String mockCode; @BeforeEach private void setup() { @@ -59,6 +60,7 @@ private void setup() { .addFilter(new CharacterEncodingFilter(StandardCharsets.UTF_8.name(), true)) .setControllerAdvice(new GlobalExceptionHandler()) .build(); + mockCode = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; } @@ -122,6 +124,25 @@ void it_return_success() throws Exception{ } } + @Nested + @DisplayName("이메일코드 인증에 실패한경우") + class Context_eamilcode_fail{ + @Test + @DisplayName("실패 ResultCode를 반환한다") + void it_return_failure() throws Exception{ + RegisterRequest registerRequest = new RegisterRequest("dlwlrma", "이지금", "a12341234", "aaa@gmail.com", "ABC123"); + when(memberAuthService.register(any())).thenReturn(false); + + mockMvc.perform(post("/accounts") + .contentType("application/json") + .content(objectMapper.writeValueAsString(registerRequest)) + ) + .andExpect(status().isOk()) + .andExpect(content().string(objectMapper.writeValueAsString( + ResultResponse.of(ResultCode.CONFIRM_EMAIL_FAIL, false)))); + } + } + @Nested @DisplayName("잘못된 parameters가 주어지면") class Context_wrong_params{ @@ -284,5 +305,121 @@ void it_return_error() throws Exception{ } } + @Nested + @DisplayName("비밀번호 변경 이메일 전송은") + class Describe_sendResetPasswordCode{ + + @Nested + @DisplayName("올바른 parameters가 주어지면") + class Context_correct_params{ + @Test + @DisplayName("성공 ResultCode를 반환한다") + void it_return_success() throws Exception{ + mockMvc.perform(post("/accounts/password/email") + .param("username", "dlwlrma") + ) + .andExpect(status().isOk()) + .andExpect(content().string(objectMapper.writeValueAsString( + ResultResponse.of(ResultCode.SEND_RESET_PASSWORD_EMAIL_SUCCESS, null)))); + } + } + + } + + @Nested + @DisplayName("비밀번호 재설정은") + class Describe_resetPassword{ + + @Nested + @DisplayName("올바른 parameters가 주어지면") + class Context_correct_params{ + @Test + @DisplayName("성공 ResultCode를 반환한다") + void it_return_success() throws Exception{ + ResetPasswordRequest resetPasswordRequest = + new ResetPasswordRequest("dlwlrma", mockCode, "a12341234"); + JwtDto jwtDto = JwtDto.builder() + .type("Bearer") + .accessToken("AAA.BBB.CCC") + .refreshToken("CCC.BBB.AAA") + .build(); + when(memberAuthService.resetPassword(any())).thenReturn(jwtDto); + + + mockMvc.perform(put("/accounts/password/reset") + .contentType("application/json") + .content(objectMapper.writeValueAsString(resetPasswordRequest)) + ) + .andExpect(cookie().value("refreshToken", jwtDto.getRefreshToken())) + .andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("잘못된 parameters가 주어지면") + class Context_wrong_params{ + @Test + @DisplayName("400 에러가 발생한다") + void it_return_error() throws Exception{ + ResetPasswordRequest resetPasswordRequest = + new ResetPasswordRequest("dlwlrma", "AAA123", "a12341234"); + + mockMvc.perform(put("/accounts/password/reset") + .contentType("application/json") + .content(objectMapper.writeValueAsString(resetPasswordRequest)) + ) + .andExpect(status().isBadRequest()); + } + } + + } + @Nested + @DisplayName("코드를 이용한 로그인은") + class Describe_loginWithCode{ + + @Nested + @DisplayName("올바른 parameters가 주어지면") + class Context_correct_params{ + @Test + @DisplayName("성공 ResultCode를 반환한다") + void it_return_success() throws Exception{ + JwtDto jwtDto = JwtDto.builder() + .type("Bearer") + .accessToken("AAA.BBB.CCC") + .refreshToken("CCC.BBB.AAA") + .build(); + when(memberAuthService.loginWithCode(any(), any())).thenReturn(jwtDto); + + + mockMvc.perform(post("/accounts/login/recovery") + .param("username", "dlwlrma") + .param("code", mockCode) + ) + .andExpect(cookie().value("refreshToken", jwtDto.getRefreshToken())) + .andExpect(status().isOk()); + } + } + + } + @Nested + @DisplayName("비밀번호 재설정 코드 만료시키기는") + class Describe_expireResetPasswordCode{ + + @Nested + @DisplayName("올바른 parameters가 주어지면") + class Context_correct_params{ + @Test + @DisplayName("성공 ResultCode를 반환한다") + void it_return_success() throws Exception{ + mockMvc.perform(delete("/accounts/login/recovery") + .param("username", "dlwlrma") + ) + .andExpect(status().isOk()) + .andExpect(content().string(objectMapper.writeValueAsString( + ResultResponse.of(ResultCode.EXPIRE_RESET_PASSWORD_CODE_SUCCESS, null)))); + } + } + + } } diff --git a/src/test/java/cloneproject/Instagram/service/MemberAuthServiceTest.java b/src/test/java/cloneproject/Instagram/service/MemberAuthServiceTest.java new file mode 100644 index 00000000..ccd15bb5 --- /dev/null +++ b/src/test/java/cloneproject/Instagram/service/MemberAuthServiceTest.java @@ -0,0 +1,205 @@ +package cloneproject.Instagram.service; + +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import cloneproject.Instagram.dto.member.JwtDto; +import cloneproject.Instagram.dto.member.LoginRequest; +import cloneproject.Instagram.dto.member.RegisterRequest; +import cloneproject.Instagram.entity.member.Member; +import cloneproject.Instagram.exception.UseridAlreadyExistException; +import cloneproject.Instagram.repository.MemberRepository; +import cloneproject.Instagram.util.JwtUtil; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +@ExtendWith(SpringExtension.class) +@DisplayName("Follow Service") +public class MemberAuthServiceTest { + + @InjectMocks + private MemberAuthService memberAuthService; + + @Mock + private JwtUtil jwtUtil; + + @Mock + private EmailCodeService emailCodeService; + + @Mock + private MemberRepository memberRepository; + + @Mock + private BCryptPasswordEncoder bCryptPasswordEncoder; + + @BeforeEach + void setRepostiory(){ + Member member = Member.builder() + .username("dlwlrma") + .name("이지금") + .email("aaa@gmail.com") + .password("1234") + .build(); + + when(memberRepository.findById(1L)).thenReturn(Optional.of(member)); + when(memberRepository.findByUsername("dlwlrma")).thenReturn(Optional.of(member)); + } + + @Nested + @DisplayName("username 중복조회 API는") + class Describe_checkUsername{ + + @Nested + @DisplayName("존재하는 경우에는") + class Context_exist{ + @DisplayName("false를 반환한다") + @Test + void it_return_false(){ + when(memberRepository.existsByUsername("dlwlrma")).thenReturn(true); + + assertEquals(false, memberAuthService.checkUsername("dlwlrma")); + } + } + @Nested + @DisplayName("존재하지 않는 경우에는") + class Context_dont_exist{ + @DisplayName("true를 반환한다") + @Test + void it_return_true(){ + assertEquals(true, memberAuthService.checkUsername("dlwlrma1")); + } + } + } + + @Nested + @DisplayName("register는") + class Describe_register{ + + @Nested + @DisplayName("username이 존재하는 경우에는") + class Context_username_already_exist{ + @DisplayName("exception을 발생시킨다") + @Test + void it_throw_exception(){ + RegisterRequest registerRequest = new RegisterRequest("dlwlrma", "이지금", "a12341234", "aaa@gmail.com", "ABC123"); + when(memberRepository.existsByUsername("dlwlrma")).thenReturn(true); + + assertThrows(UseridAlreadyExistException.class, ()->memberAuthService.register(registerRequest)); + } + } + + @Nested + @DisplayName("EmailCode가 올바르지 않은 경우에는") + class Context_dont_exist{ + @DisplayName("false를 반환한다") + @Test + void it_return_false(){ + RegisterRequest registerRequest = new RegisterRequest("dlwlrma", "이지금", "a12341234", "aaa@gmail.com", "ABC123"); + assertEquals(false, memberAuthService.register(registerRequest)); + } + } + + @Nested + @DisplayName("정상적인 경우에는") + class Context_correct_process{ + @DisplayName("true를 반환한다") + @Test + void it_return_true(){ + RegisterRequest registerRequest = new RegisterRequest("dlwlrma", "이지금", "a12341234", "aaa@gmail.com", "ABC123"); + when(emailCodeService.checkEmailCode(any(), any(), any())).thenReturn(true); + + assertEquals(true, memberAuthService.register(registerRequest)); + } + } + } + + @Nested + @DisplayName("sendEmailConfirmation는") + class Describe_sendEmailConfirmation{ + + @Nested + @DisplayName("username이 존재하는 경우에는") + class Context_username_exist{ + @DisplayName("exception을 발생시킨다") + @Test + void it_throw_exception(){ + when(memberRepository.existsByUsername("dlwlrma")).thenReturn(true); + + assertThrows(UseridAlreadyExistException.class, ()->memberAuthService.sendEmailConfirmation("dlwlrma", "")); + } + } + @Nested + @DisplayName("username이 존재하지 않는 경우에는") + class Context_username_dont_exist{ + @DisplayName("정상적으로 실행된다") + @Test + void it_do_well(){ + memberAuthService.sendEmailConfirmation("dlwlrma", ""); + verify(emailCodeService, times(1)).sendEmailConfirmationCode(any(), any()); + } + } + } + + // TODO AuthenticationManagerBuilder NullPointerException 해결 + // @Nested + // @DisplayName("login은") + // class Describe_login{ + + // @Nested + // @DisplayName("올바른 정보를 입력하면") + // class Context_correct_account{ + // @DisplayName("JWT 토큰이 반환된다") + // @Test + // void it_return_jwt() throws Exception{ + // LoginRequest loginRequest = new LoginRequest("dlwlrma", "a12341234"); + // JwtDto jwtDto = JwtDto.builder() + // .type("Bearer") + // .accessToken("AAA.BBB.CCC") + // .refreshToken("CCC.BBB.AAA") + // .build(); + // when(jwtUtil.generateTokenDto(any())).thenReturn(jwtDto); + + // assertEquals(jwtDto, memberAuthService.login(loginRequest)); + // } + // } + + // @Nested + // @DisplayName("EmailCode가 올바르지 않은 경우에는") + // class Context_dont_exist{ + // @DisplayName("false를 반환한다") + // @Test + // void it_return_false(){ + // RegisterRequest registerRequest = new RegisterRequest("dlwlrma", "이지금", "a12341234", "aaa@gmail.com", "ABC123"); + // assertEquals(false, memberAuthService.register(registerRequest)); + // } + // } + + // @Nested + // @DisplayName("정상적인 경우에는") + // class Context_correct_process{ + // @DisplayName("true를 반환한다") + // @Test + // void it_return_true(){ + // RegisterRequest registerRequest = new RegisterRequest("dlwlrma", "이지금", "a12341234", "aaa@gmail.com", "ABC123"); + // when(emailCodeService.checkEmailCode(any(), any(), any())).thenReturn(true); + + // assertEquals(true, memberAuthService.register(registerRequest)); + // } + // } + // } + +} From d969e297f05569a3a443cf8e37c849009dbbe69d Mon Sep 17 00:00:00 2001 From: DH CHOI Date: Mon, 7 Mar 2022 20:23:43 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Refactor:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=9D=B8=EC=A6=9D=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원가입 인증코드를 전송하는 메일에 HTML 적용 - HTML을 전송하기 위해 EmailService 로직 변경 - 이메일 관련 예외처리 추가 --- .../Instagram/dto/error/ErrorCode.java | 5 +- .../exception/CantSendEmailException.java | 10 + .../Instagram/service/EmailCodeService.java | 20 +- .../Instagram/service/EmailService.java | 18 ++ src/main/resources/confirmEmailUI.html | 281 ++++++++++++++++++ 5 files changed, 326 insertions(+), 8 deletions(-) create mode 100644 src/main/java/cloneproject/Instagram/exception/CantSendEmailException.java create mode 100644 src/main/resources/confirmEmailUI.html diff --git a/src/main/java/cloneproject/Instagram/dto/error/ErrorCode.java b/src/main/java/cloneproject/Instagram/dto/error/ErrorCode.java index 1cdc1d21..ba86192a 100644 --- a/src/main/java/cloneproject/Instagram/dto/error/ErrorCode.java +++ b/src/main/java/cloneproject/Instagram/dto/error/ErrorCode.java @@ -74,7 +74,10 @@ public enum ErrorCode { CANT_CONVERT_FILE(500, "FI001", "파일을 변환할 수 없습니다."), // Alarm - MISMATCHED_ALARM_TYPE(400, "A001", "알람 형식이 올바르지 않습니다.") + MISMATCHED_ALARM_TYPE(400, "A001", "알람 형식이 올바르지 않습니다."), + + // Email + CANT_SEND_EMAIL(500, "E001", "이메일 전송 중 오류가 발생했습니다.") ; private int status; diff --git a/src/main/java/cloneproject/Instagram/exception/CantSendEmailException.java b/src/main/java/cloneproject/Instagram/exception/CantSendEmailException.java new file mode 100644 index 00000000..56818619 --- /dev/null +++ b/src/main/java/cloneproject/Instagram/exception/CantSendEmailException.java @@ -0,0 +1,10 @@ +package cloneproject.Instagram.exception; + +import cloneproject.Instagram.dto.error.ErrorCode; + +public class CantSendEmailException extends BusinessException{ + public CantSendEmailException(){ + super(ErrorCode.CANT_SEND_EMAIL); + } + +} diff --git a/src/main/java/cloneproject/Instagram/service/EmailCodeService.java b/src/main/java/cloneproject/Instagram/service/EmailCodeService.java index 4b93c0fc..0da3cb22 100644 --- a/src/main/java/cloneproject/Instagram/service/EmailCodeService.java +++ b/src/main/java/cloneproject/Instagram/service/EmailCodeService.java @@ -1,8 +1,11 @@ package cloneproject.Instagram.service; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Optional; import java.util.Random; +import org.springframework.core.io.ClassPathResource; import org.springframework.mail.SimpleMailMessage; import org.springframework.stereotype.Service; @@ -10,6 +13,7 @@ import cloneproject.Instagram.entity.redis.EmailCode; import cloneproject.Instagram.entity.redis.ResetPasswordCode; import cloneproject.Instagram.exception.CantResetPasswordException; +import cloneproject.Instagram.exception.CantSendEmailException; import cloneproject.Instagram.exception.MemberDoesNotExistException; import cloneproject.Instagram.exception.NoConfirmEmailException; import cloneproject.Instagram.repository.EmailCodeRedisRepository; @@ -29,9 +33,15 @@ public class EmailCodeService { private final EmailService emailService; public boolean sendEmailConfirmationCode(String username, String email){ - + String text; String code = createConfirmationCode(6); - + try{ + ClassPathResource resource = new ClassPathResource("confirmEmailUI.html"); + String html = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + text = String.format(html, email, code, email); + }catch(IOException e){ + throw new CantSendEmailException(); + } EmailCode emailCode = EmailCode.builder() .username(username) .email(email) @@ -39,11 +49,7 @@ public boolean sendEmailConfirmationCode(String username, String email){ .build(); emailCodeRedisRepository.save(emailCode); - SimpleMailMessage mailMessage = new SimpleMailMessage(); - mailMessage.setTo(email); - mailMessage.setSubject("회원가입 이메일 인증코드"); - mailMessage.setText(code); - emailService.sendEmail(mailMessage); + emailService.sendHtmlTextEmail(username+ ", welcome to Instagram." ,text, email); return true; } diff --git a/src/main/java/cloneproject/Instagram/service/EmailService.java b/src/main/java/cloneproject/Instagram/service/EmailService.java index 3000c2aa..5c5288ce 100644 --- a/src/main/java/cloneproject/Instagram/service/EmailService.java +++ b/src/main/java/cloneproject/Instagram/service/EmailService.java @@ -1,10 +1,14 @@ package cloneproject.Instagram.service; +import javax.mail.internet.MimeMessage; + import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import cloneproject.Instagram.exception.CantSendEmailException; import lombok.RequiredArgsConstructor; @Service @@ -18,4 +22,18 @@ public void sendEmail(SimpleMailMessage email){ javaMailSender.send(email); } + @Async + public void sendHtmlTextEmail(String subject, String content, String email){ + MimeMessage message = javaMailSender.createMimeMessage(); + try { + MimeMessageHelper messageHelper = new MimeMessageHelper(message, true, "UTF-8"); + messageHelper.setTo(email); + messageHelper.setSubject(subject); + messageHelper.setText(content, true); + javaMailSender.send(message); + }catch(Exception e){ + throw new CantSendEmailException(); + } + } + } diff --git a/src/main/resources/confirmEmailUI.html b/src/main/resources/confirmEmailUI.html new file mode 100644 index 00000000..b027e1f0 --- /dev/null +++ b/src/main/resources/confirmEmailUI.html @@ -0,0 +1,281 @@ + + + + + + + + Instagram + + + + + + + + + + + + + + + + + + + + + + + +
 
+ + + + + + + + +
+ + + + + + +
+ +
+
+
+ + + + + + +
+ + + + + + + + + + +
+     +
+     + + + + + + + + + + + + +
+

+ Hi, +

+

+ + Someone tried to sign up for an Instagram + account with %s. If it + was you, enter this confirmation code in + the app: +

+
+ + %s +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+     +
+ + + + meta logo + + + +
+
+ © Instagram. Meta Platforms, Inc., 1601 Willow Road, Menlo + Park, CA 94025 +
+ + This message was sent to %s. +
   
+   +
+
 
+ +