diff --git a/build.gradle b/build.gradle index 55fb2b284..99fac61f6 100644 --- a/build.gradle +++ b/build.gradle @@ -216,7 +216,7 @@ tasks.register('unitTest', Test) { includeTags 'unit' } } -tasks.register('check rule', Test) { +tasks.register('check_rule', Test) { useJUnitPlatform { includeTags 'rule' } diff --git a/git.environment-variables b/git.environment-variables index cd96d787a..cbda8dc1e 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit cd96d787a9bb9e9554ae209da068ab3060344bdd +Subproject commit cbda8dc1ef34507f4803ad7c778d854fa5c9cec2 diff --git a/src/docs/asciidoc/api/auth/auth.adoc b/src/docs/asciidoc/api/auth/auth.adoc new file mode 100644 index 000000000..fcb8c81cf --- /dev/null +++ b/src/docs/asciidoc/api/auth/auth.adoc @@ -0,0 +1,23 @@ +=== 루트 관리자 권한 검증 === + +- 현재 로그인한 사용자가 루트 관리자 권한을 가지고 있는지 검증합니다. +- 개발 / 운영 환경이 변경 가능한지 검증하는데 활용 할 수 있습니다. + +[source] +---- +api/v2/auth/admin/permissions +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/auth/admin/root-permissions/curl-request.adoc[] +include::{snippets}/auth/admin/root-permissions/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/auth/admin/root-permissions/response-fields.adoc[] +include::{snippets}/auth/admin/root-permissions/http-response.adoc[] diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 48b8b30bc..b206b06c9 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -84,6 +84,11 @@ include::api/overview/global-auth.adoc[] ''' include::api/file/image/upload/presign-url.adoc[] +== 권한 (auth) 관련 API + +''' +include::api/auth/auth.adoc[] + == 회원 (user) 관련 API ''' diff --git a/src/main/java/app/bottlenote/user/controller/AuthV2Controller.java b/src/main/java/app/bottlenote/user/controller/AuthV2Controller.java new file mode 100644 index 000000000..cf20e64f4 --- /dev/null +++ b/src/main/java/app/bottlenote/user/controller/AuthV2Controller.java @@ -0,0 +1,32 @@ +package app.bottlenote.user.controller; + +import app.bottlenote.global.data.response.GlobalResponse; +import app.bottlenote.global.security.SecurityContextUtil; +import app.bottlenote.user.exception.UserException; +import app.bottlenote.user.service.AuthService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static app.bottlenote.user.exception.UserExceptionCode.REQUIRED_USER_ID; + + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v2/auth") +public class AuthV2Controller { + private final AuthService authService; + + @GetMapping("/admin/permissions") + public ResponseEntity checkAdminStatus() { + Long currentUserId = SecurityContextUtil.getUserIdByContext(). + orElseThrow(() -> new UserException(REQUIRED_USER_ID)); + + boolean is = authService.checkAdminStatus(currentUserId); + return GlobalResponse.ok(is); + } +} diff --git a/src/main/java/app/bottlenote/user/domain/RootAdmin.java b/src/main/java/app/bottlenote/user/domain/RootAdmin.java new file mode 100644 index 000000000..413201702 --- /dev/null +++ b/src/main/java/app/bottlenote/user/domain/RootAdmin.java @@ -0,0 +1,29 @@ +package app.bottlenote.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity(name = "root_admins") +@Table(name = "root_admins") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RootAdmin { + + @Id + @Column(name = "user_id") + private Long userId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + @MapsId + private User user; +} diff --git a/src/main/java/app/bottlenote/user/repository/RootAdminRepository.java b/src/main/java/app/bottlenote/user/repository/RootAdminRepository.java new file mode 100644 index 000000000..7e3a5d6c1 --- /dev/null +++ b/src/main/java/app/bottlenote/user/repository/RootAdminRepository.java @@ -0,0 +1,8 @@ +package app.bottlenote.user.repository; + +import app.bottlenote.user.domain.RootAdmin; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RootAdminRepository extends JpaRepository { + boolean existsByUserId(Long userId); +} diff --git a/src/main/java/app/bottlenote/user/service/AuthService.java b/src/main/java/app/bottlenote/user/service/AuthService.java new file mode 100644 index 000000000..6c7260d93 --- /dev/null +++ b/src/main/java/app/bottlenote/user/service/AuthService.java @@ -0,0 +1,20 @@ +package app.bottlenote.user.service; + +import app.bottlenote.user.repository.RootAdminRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final RootAdminRepository rootAdminRepository; + + @Transactional(readOnly = true) + public boolean checkAdminStatus(Long userId) { + return rootAdminRepository.existsByUserId(userId); + } +} diff --git a/src/test/java/app/docs/user/RestAuthV2ControllerTest.java b/src/test/java/app/docs/user/RestAuthV2ControllerTest.java new file mode 100644 index 000000000..0124a9918 --- /dev/null +++ b/src/test/java/app/docs/user/RestAuthV2ControllerTest.java @@ -0,0 +1,80 @@ +package app.docs.user; + +import app.bottlenote.global.security.SecurityContextUtil; +import app.bottlenote.user.controller.AuthV2Controller; +import app.bottlenote.user.service.AuthService; +import app.docs.AbstractRestDocs; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.http.MediaType; + +import java.util.Optional; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Tag("document") +@DisplayName("유저 Auth 컨트롤러 V2x RestDocs 테스트") +class RestAuthV2ControllerTest extends AbstractRestDocs { + private final AuthService authService = mock(AuthService.class); + private MockedStatic mockedSecurityUtil; + + @Override + protected Object initController() { + return new AuthV2Controller(authService); + } + + @BeforeEach + void setup() { + mockedSecurityUtil = mockStatic(SecurityContextUtil.class); + } + + + @AfterEach + void tearDown() { + mockedSecurityUtil.close(); + } + + @Test + @DisplayName("루트 어드민 검증을 수행합니다.") + void login_test() throws Exception { + + //given + final long userId = 1L; + when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); + + //when + when(authService.checkAdminStatus(userId)).thenReturn(true); + + //then + mockMvc.perform(get("/api/v2/auth/admin/permissions") + .contentType(MediaType.APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isOk()) + .andDo( + document("auth/admin/root-permissions", + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드(http status code)"), + fieldWithPath("data").description("검증 결과"), + fieldWithPath("errors").description("응답 성공 여부가 false일 경우 에러 메시지(없을 경우 null)"), + fieldWithPath("meta.serverEncoding").description("서버 인코딩 정도"), + fieldWithPath("meta.serverVersion").description("서버 버전"), + fieldWithPath("meta.serverPathVersion").description("서버 경로 버전"), + fieldWithPath("meta.serverResponseTime").description("서버 응답 시간") + ) + ) + ); + } +} diff --git a/src/test/java/app/rule/api/ControllerLayerRules.java b/src/test/java/app/rule/api/ControllerLayerRules.java index 90944632f..c04677741 100644 --- a/src/test/java/app/rule/api/ControllerLayerRules.java +++ b/src/test/java/app/rule/api/ControllerLayerRules.java @@ -341,6 +341,7 @@ public void check(JavaMethod method, ConditionEvents events) { allowedPrefixesMap.put("restore", "복원하다"); allowedPrefixesMap.put("reissue", "재발급하다"); allowedPrefixesMap.put("signup", "가입하다"); + allowedPrefixesMap.put("check", "확인하다"); boolean startsWithVerb = allowedPrefixesMap.keySet().stream() .anyMatch(methodName::startsWith);