diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 81ed733..3627ca0 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -10,6 +10,11 @@ on: branches: - 'feature/**' +permissions: + contents: read + checks: write + pull-requests: write + jobs: build-and-test: runs-on: ubuntu-latest diff --git a/account-service/build.gradle b/account-service/build.gradle index a03aef4..1c750b3 100644 --- a/account-service/build.gradle +++ b/account-service/build.gradle @@ -40,6 +40,8 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' //H2 Database implementation 'com.h2database:h2' + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' } tasks.named('test') { diff --git a/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java b/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java new file mode 100644 index 0000000..49d8403 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java @@ -0,0 +1,33 @@ +package com.synapse.account_service.config; + +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.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/accounts/signup").permitAll() + .anyRequest().authenticated() + ); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/controller/AccountController.java b/account-service/src/main/java/com/synapse/account_service/controller/AccountController.java new file mode 100644 index 0000000..0e823e3 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/controller/AccountController.java @@ -0,0 +1,28 @@ +package com.synapse.account_service.controller; + +import org.springframework.http.ResponseEntity; +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.RestController; + +import com.synapse.account_service.dto.request.SignUpRequest; +import com.synapse.account_service.dto.response.SignUpResponse; +import com.synapse.account_service.service.AccountService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +import static org.springframework.http.HttpStatus.CREATED; + +@RestController +@RequestMapping("/api/accounts") +@RequiredArgsConstructor +public class AccountController { + private final AccountService accountService; + + @PostMapping("/signup") + public ResponseEntity signUp(@Valid @RequestBody SignUpRequest request) { + return ResponseEntity.status(CREATED).body(accountService.registerMember(request)); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/domain/Member.java b/account-service/src/main/java/com/synapse/account_service/domain/Member.java index 0303a9f..73e6df2 100644 --- a/account-service/src/main/java/com/synapse/account_service/domain/Member.java +++ b/account-service/src/main/java/com/synapse/account_service/domain/Member.java @@ -1,5 +1,7 @@ package com.synapse.account_service.domain; +import org.springframework.security.crypto.password.PasswordEncoder; + import com.synapse.account_service.common.BaseEntity; import com.synapse.account_service.domain.enums.MemberRole; @@ -19,10 +21,10 @@ public class Member extends BaseEntity { @Column(name = "member_id") private Long id; - @Column(name = "username") + @Column(name = "username", nullable = false) private String username; - @Column(name = "password") + @Column(name = "password", nullable = false) private String password; @Column(name = "email", nullable = false, unique = true, length = 50) @@ -61,4 +63,8 @@ public void setSubscription(Subscription subscription) { subscription.setMemberInternal(this); // 무한 루프 방지를 위해 내부 메서드 호출 } } + + public void encodePassword(PasswordEncoder passwordEncoder) { + this.password = passwordEncoder.encode(this.password); + } } diff --git a/account-service/src/main/java/com/synapse/account_service/dto/request/SignUpRequest.java b/account-service/src/main/java/com/synapse/account_service/dto/request/SignUpRequest.java new file mode 100644 index 0000000..453fa3f --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/dto/request/SignUpRequest.java @@ -0,0 +1,20 @@ +package com.synapse.account_service.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record SignUpRequest( + @NotBlank(message = "이메일은 필수 입력 항목입니다.") + @Email(message = "유효한 이메일 형식이 아닙니다.") + String email, + + @NotBlank(message = "사용자 이름은 필수 입력 항목입니다.") + String username, + + @NotBlank(message = "비밀번호는 필수 입력 항목입니다.") + @Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.") + String password +) { + +} diff --git a/account-service/src/main/java/com/synapse/account_service/dto/response/SignUpResponse.java b/account-service/src/main/java/com/synapse/account_service/dto/response/SignUpResponse.java new file mode 100644 index 0000000..94691be --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/dto/response/SignUpResponse.java @@ -0,0 +1,14 @@ +package com.synapse.account_service.dto.response; + +import com.synapse.account_service.domain.Member; + +public record SignUpResponse( + Long id, + String email, + String username, + String role +) { + public static SignUpResponse from(Member member) { + return new SignUpResponse(member.getId(), member.getEmail(), member.getUsername(), member.getRole().name()); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/exception/AccountServiceException.java b/account-service/src/main/java/com/synapse/account_service/exception/AccountServiceException.java new file mode 100644 index 0000000..f848d0b --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/exception/AccountServiceException.java @@ -0,0 +1,13 @@ +package com.synapse.account_service.exception; + +import lombok.Getter; + +@Getter +public abstract class AccountServiceException extends RuntimeException { + private final ExceptionType exceptionType; + + protected AccountServiceException(final ExceptionType exceptionType) { + super(exceptionType.getMessage()); + this.exceptionType = exceptionType; + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/exception/DuplicatedException.java b/account-service/src/main/java/com/synapse/account_service/exception/DuplicatedException.java new file mode 100644 index 0000000..ad3555a --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/exception/DuplicatedException.java @@ -0,0 +1,7 @@ +package com.synapse.account_service.exception; + +public class DuplicatedException extends AccountServiceException { + public DuplicatedException(ExceptionType exceptionType) { + super(exceptionType); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java b/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java new file mode 100644 index 0000000..5d31e1b --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/exception/ExceptionType.java @@ -0,0 +1,23 @@ +package com.synapse.account_service.exception; + +import org.springframework.http.HttpStatus; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum ExceptionType { + DUPLICATED_EMAIL(CONFLICT, "001", "이미 존재하는 이메일입니다."), + DUPLICATED_USERNAME(CONFLICT, "002", "이미 존재하는 사용자 이름입니다."), + EXCEPTION(INTERNAL_SERVER_ERROR, "003", "예상치 못한 오류가 발생했습니다.") + ; + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/account-service/src/main/java/com/synapse/account_service/exception/GlobalExceptionHandler.java b/account-service/src/main/java/com/synapse/account_service/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..ce28acf --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/exception/GlobalExceptionHandler.java @@ -0,0 +1,63 @@ +package com.synapse.account_service.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import com.synapse.account_service.exception.dto.ExceptionResponse; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + private static final Logger log = LoggerFactory.getLogger("ErrorLogger"); + + private static final String LOG_FORMAT_INFO_PATTERN = "[🔵INFO] - (%s %s)\nExceptionType: %s\n %s: %s"; + private static final String LOG_FORMAT_WARN_PATTERN = "[🟠WARN] - (%s %s)\nExceptionType: %s\n %s: %s"; + private static final String LOG_FORMAT_ERROR_PATTERN = "[🔴ERROR] - (%s %s)\nExceptionType: %s\n %s: %s"; + + @ExceptionHandler(AccountServiceException.class) + public ResponseEntity handleAccountServiceException(AccountServiceException e, HttpServletRequest request) { + logInfo(e, request); + return ResponseEntity.status(e.getExceptionType().getStatus()).body(ExceptionResponse.from(e)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e, HttpServletRequest request) { + logError(e, request); + return ResponseEntity + .status(ExceptionType.EXCEPTION.getStatus()) + .body(new ExceptionResponse(ExceptionType.EXCEPTION.getCode(), ExceptionType.EXCEPTION.getMessage())); + } + + private void logInfo(AccountServiceException e, HttpServletRequest request) { + log.info(String.format(LOG_FORMAT_INFO_PATTERN, + request.getMethod(), + request.getRequestURI(), + e.getExceptionType(), + e.getClass().getName(), + e.getMessage())); + } + + private void logWarn(AccountServiceException e, HttpServletRequest request) { + log.warn(String.format(LOG_FORMAT_WARN_PATTERN, + request.getMethod(), + request.getRequestURI(), + e.getExceptionType(), + e.getClass().getName(), + e.getMessage())); + } + + private void logError(Exception e, HttpServletRequest request) { + log.error(String.format(LOG_FORMAT_ERROR_PATTERN, + request.getMethod(), + request.getRequestURI(), + e.getClass().getName(), + e.getMessage())); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/exception/dto/ExceptionResponse.java b/account-service/src/main/java/com/synapse/account_service/exception/dto/ExceptionResponse.java new file mode 100644 index 0000000..0337fe0 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/exception/dto/ExceptionResponse.java @@ -0,0 +1,17 @@ +package com.synapse.account_service.exception.dto; + +import com.synapse.account_service.exception.AccountServiceException; +import com.synapse.account_service.exception.ExceptionType; + +public record ExceptionResponse( + String code, + String message +) { + public static ExceptionResponse from(AccountServiceException exception) { + return ExceptionResponse.from(exception.getExceptionType()); + } + + public static ExceptionResponse from(ExceptionType exceptionType) { + return new ExceptionResponse(exceptionType.getCode(), exceptionType.getMessage()); + } +} diff --git a/account-service/src/main/java/com/synapse/account_service/repository/MemberRepository.java b/account-service/src/main/java/com/synapse/account_service/repository/MemberRepository.java index 7f2f3af..0a3c691 100644 --- a/account-service/src/main/java/com/synapse/account_service/repository/MemberRepository.java +++ b/account-service/src/main/java/com/synapse/account_service/repository/MemberRepository.java @@ -8,5 +8,6 @@ public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); + Optional findByUsername(String username); Optional findByProviderAndRegistrationId(String provider, String registrationId); } diff --git a/account-service/src/main/java/com/synapse/account_service/service/AccountService.java b/account-service/src/main/java/com/synapse/account_service/service/AccountService.java new file mode 100644 index 0000000..81c43ba --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/service/AccountService.java @@ -0,0 +1,75 @@ +package com.synapse.account_service.service; + +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Optional; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.Subscription; +import com.synapse.account_service.domain.enums.MemberRole; +import com.synapse.account_service.domain.enums.SubscriptionTier; +import com.synapse.account_service.dto.request.SignUpRequest; +import com.synapse.account_service.dto.response.SignUpResponse; +import com.synapse.account_service.exception.ExceptionType; +import com.synapse.account_service.exception.DuplicatedException; +import com.synapse.account_service.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class AccountService { + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + public Optional findMemberByEmail(String email) { + return memberRepository.findByEmail(email); + } + + public Optional findMemberByUsername(String username) { + return memberRepository.findByUsername(username); + } + + public SignUpResponse registerMember(SignUpRequest request) { + findMemberByEmail(request.email()).ifPresent(m -> { + throw new DuplicatedException(ExceptionType.DUPLICATED_EMAIL); + }); + + findMemberByUsername(request.username()).ifPresent(m -> { + throw new DuplicatedException(ExceptionType.DUPLICATED_USERNAME); + }); + + Member member = Member.builder() + .email(request.email()) + .password(request.password()) + .username(request.username()) + .role(MemberRole.USER) // 기본 역할은 USER + .provider("local") // 일반 회원가입이므로 "local"로 지정 + .build(); + + member.encodePassword(passwordEncoder); + + createAndSetDefaultSubscription(member); + + memberRepository.save(member); + + return SignUpResponse.from(member); + } + + private void createAndSetDefaultSubscription(Member member) { + ZonedDateTime nextRenewalDate = ZonedDateTime.now(ZoneId.systemDefault()).plusDays(1).with(LocalTime.MIDNIGHT); // 무료 사용자는 자정 초기화 + + Subscription freeSubscription = Subscription.builder() + .tier(SubscriptionTier.FREE) + .nextRenewalDate(nextRenewalDate) + .build(); + + member.setSubscription(freeSubscription); + } +} diff --git a/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java b/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java new file mode 100644 index 0000000..93d63f8 --- /dev/null +++ b/account-service/src/test/java/com/synapse/account_service/controller/AccountControllerTest.java @@ -0,0 +1,91 @@ +package com.synapse.account_service.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.springframework.http.MediaType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.synapse.account_service.config.SecurityConfig; +import com.synapse.account_service.dto.request.SignUpRequest; +import com.synapse.account_service.dto.response.SignUpResponse; +import com.synapse.account_service.exception.ExceptionType; +import com.synapse.account_service.exception.GlobalExceptionHandler; +import com.synapse.account_service.exception.DuplicatedException; +import com.synapse.account_service.service.AccountService; + +@WebMvcTest(AccountController.class) // AccountController만 테스트 +@Import({GlobalExceptionHandler.class, SecurityConfig.class}) +public class AccountControllerTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private AccountService accountService; + + @Test + @DisplayName("회원가입 API 호출 성공") + void signUpApi_success() throws Exception { + // given + SignUpRequest request = new SignUpRequest("test@example.com", "유저", "password1234"); + SignUpResponse response = new SignUpResponse(1L, "test@example.com", "유저", "USER"); + + given(accountService.registerMember(any(SignUpRequest.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/accounts/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) // 201 Created 상태인지 확인 + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.username").value("유저")) + .andExpect(jsonPath("$.role").value("USER")); + } + + @Test + @DisplayName("이메일 중복 시 409 Conflict 응답") + void signUpApi_fail_withDuplicateEmail() throws Exception { + // given + SignUpRequest request = new SignUpRequest("test1@example.com", "유저", "password1234"); + + // accountService.registerMember가 호출되면 BusinessException을 던지도록 설정 + given(accountService.registerMember(any(SignUpRequest.class))) + .willThrow(new DuplicatedException(ExceptionType.DUPLICATED_EMAIL)); + + // when & then + mockMvc.perform(post("/api/accounts/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) // 409 Conflict 상태인지 확인 + .andExpect(jsonPath("$.code").value(ExceptionType.DUPLICATED_EMAIL.getCode())); + } + + @Test + @DisplayName("잘못된 요청값으로 회원가입 API 호출 시 400 Bad Request 응답") + void signUpApi_fail_withInvalidInput() throws Exception { + // given + // 이메일 형식이 잘못된 요청 + SignUpRequest request = new SignUpRequest("test.com", "password1234", "유저"); + + // when & then + mockMvc.perform(post("/api/accounts/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); // @Valid에 의해 400 Bad Request가 발생하는지 확인 + } +} diff --git a/account-service/src/test/java/com/synapse/account_service/service/AccountServiceTest.java b/account-service/src/test/java/com/synapse/account_service/service/AccountServiceTest.java new file mode 100644 index 0000000..38a925f --- /dev/null +++ b/account-service/src/test/java/com/synapse/account_service/service/AccountServiceTest.java @@ -0,0 +1,83 @@ +package com.synapse.account_service.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.ArgumentMatchers.any; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import com.synapse.account_service.domain.Member; +import com.synapse.account_service.domain.enums.MemberRole; +import com.synapse.account_service.dto.request.SignUpRequest; +import com.synapse.account_service.dto.response.SignUpResponse; +import com.synapse.account_service.exception.DuplicatedException; +import com.synapse.account_service.repository.MemberRepository; + +@ExtendWith(MockitoExtension.class) +public class AccountServiceTest { + + @InjectMocks + private AccountService accountService; + + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Test + @DisplayName("회원가입 성공") + void signUp_success() { + // given: 테스트 준비 + SignUpRequest request = new SignUpRequest("test@example.com", "테스트유저", "password123"); + Member member = Member.builder() + .email(request.email()) + .password("encodedPassword") + .role(MemberRole.USER) + .build(); + + // memberRepository.findByEmail이 호출되면, 비어있는 Optional을 반환하도록 설정 (중복 없음) + given(memberRepository.findByEmail(anyString())).willReturn(Optional.empty()); + // passwordEncoder.encode가 호출되면, "encodedPassword"를 반환하도록 설정 + given(passwordEncoder.encode(anyString())).willReturn("encodedPassword"); + // memberRepository.save가 호출되면, 준비된 member 객체를 반환하도록 설정 + given(memberRepository.save(any(Member.class))).willReturn(member); + + // when: 실제 테스트할 메서드 호출 + SignUpResponse response = accountService.registerMember(request); + + // then: 결과 검증 + assertThat(response.email()).isEqualTo("test@example.com"); + + // passwordEncoder.encode가 한 번 호출되었는지 검증 + verify(passwordEncoder).encode("password123"); + // memberRepository.save가 한 번 호출되었는지 검증 + verify(memberRepository).save(any(Member.class)); + } + + @Test + @DisplayName("이메일 중복으로 회원가입 실패") + void signUp_fail_withDuplicateEmail() { + // given + SignUpRequest request = new SignUpRequest("test@example.com", "테스트유저", "password123"); + + // memberRepository.findByEmail이 호출되면, 이미 존재하는 Member 객체를 반환하도록 설정 + given(memberRepository.findByEmail(anyString())).willReturn(Optional.of(Member.builder().build())); + + // when & then: BusinessException이 발생하는지 검증 + assertThrows(DuplicatedException.class, () -> { + accountService.registerMember(request); + }); + } +} diff --git a/account-service/src/test/resources/application-test.yml b/account-service/src/test/resources/application-test.yml index b2a30b2..94752a0 100644 --- a/account-service/src/test/resources/application-test.yml +++ b/account-service/src/test/resources/application-test.yml @@ -18,7 +18,7 @@ spring: highlight: sql: true hbm2ddl: - auto: create-drop + auto: create dialect: org.hibernate.dialect.PostgreSQLDialect open-in-view: false show-sql: true