이번 시간에는 게시글 삭제 API를 작성해보겠습니다.
게시글을 삭제할 때는, 파일 시스템에 업로드된 이미지도 함께 삭제해야합니다.
지난 시간에 미완성으로 남겨뒀던, service.file.LocalFileService에 delete 메소드를 구현해보겠습니다.
// LocalFileService.java
@Override
public void delete(String filename) {
new File(location + filename).delete();
}
전달받은 파일명을 이용하여 파일 삭제를 수행합니다.
LocalFileServiceTest에서 바로 테스트해보겠습니다.
package kukekyakya.kukemarket.service.file;
import ...
class LocalFileServiceTest {
LocalFileService localFileService = new LocalFileService();
String testLocation = new File("src/test/resources/static").getAbsolutePath() + "/";
@BeforeEach
void beforeEach() throws IOException {
ReflectionTestUtils.setField(localFileService, "location", testLocation);
FileUtils.cleanDirectory(new File(testLocation));
}
...
@Test
void deleteTest() {
// given
MultipartFile file = new MockMultipartFile("myFile", "myFile.txt", MediaType.TEXT_PLAIN_VALUE, "test".getBytes());
String filename = "testFile.txt";
localFileService.upload(file, filename);
boolean before = isExists(testLocation + filename);
// when
localFileService.delete(filename);
// then
boolean after = isExists(testLocation + filename);
assertThat(before).isTrue();
assertThat(after).isFalse();
}
boolean isExists(String filePath) {
return new File(filePath).exists();
}
}
자세한 설명은 생략하겠습니다.
이제 게시글 삭제 로직을 작성해봅시다.
service.post.PostService에 다음과 같은 메소드를 추가해줍니다.
// PostService.java
@Transactional
public void delete(Long id) {
Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new);
deleteImages(post.getImages());
postRepository.delete(post);
}
private void deleteImages(List<Image> images) {
images.stream().forEach(i -> fileService.delete(i.getUniqueName()));
}
게시글과 업로드된 이미지들을 제거해줍니다.
PostServiceTest에 다음과 같은 테스트를 추가해줍니다.
// PostServiceTest.java
@Test
void deleteTest() {
// given
Post post = createPostWithImages(List.of(createImage(), createImage()));
given(postRepository.findById(anyLong())).willReturn(Optional.of(post));
// when
postService.delete(1L);
// then
verify(fileService, times(post.getImages().size())).delete(anyString());
verify(postRepository).delete(any());
}
@Test
void deleteExceptionByNotFoundPostTest() {
// given
given(postRepository.findById(anyLong())).willReturn(Optional.ofNullable(null));
// when, then
assertThatThrownBy(() -> postService.delete(1L)).isInstanceOf(PostNotFoundException.class);
}
자세한 설명은 생략하겠습니다.
controller.post.PostController에 삭제 요청 API도 추가해주겠습니다.
// PostController.java
@ApiOperation(value = "게시글 삭제", notes = "게시글을 삭제한다.")
@DeleteMapping("/api/posts/{id}")
@ResponseStatus(HttpStatus.OK)
public Response delete(@ApiParam(value = "게시글 id", required = true) @PathVariable Long id) {
postService.delete(id);
return Response.success();
}
삭제에 성공하면, 200 상태코드를 응답해줍니다.
security 설정도 해주겠습니다.
게시글 삭제는, 관리자와 게시글의 작성자만 수행할 수 있습니다.
사용자 삭제 권한 검사에 MemberGuard를 이용했던 것처럼, 게시글 삭제도 PostGuard를 이용해서 접근을 제어해주겠습니다.
config.security.guard.PostGuard를 작성해줍니다.
@Component
@RequiredArgsConstructor
@Slf4j
public class PostGuard {
private final AuthHelper authHelper;
private final PostRepository postRepository;
public boolean check(Long id) {
return authHelper.isAuthenticated() && hasAuthority(id);
}
private boolean hasAuthority(Long id) {
return hasAdminRole() || isResourceOwner(id); // 1
}
private boolean isResourceOwner(Long id) {
Post post = postRepository.findById(id).orElseThrow(() -> { throw new AccessDeniedException(""); });
Long memberId = authHelper.extractMemberId();
return post.getMember().getId().equals(memberId);
}
private boolean hasAdminRole() {
return authHelper.extractMemberRoles().contains(RoleType.ROLE_ADMIN);
}
}
요청자가 관리자이거나 게시글의 작성자라면, 요청을 수행할 수 있습니다.
1. 주의할 점은 반드시 관리자 권한 검사가, 자원의 소유자 검사보다 먼저 이뤄져야한다는 것입니다.
데이터베이스에 접근하는 비용은 비쌉니다. 따라서, 데이터베이스 접근은 최소한으로 유지하는 것이 좋습니다.
만약 관리자 권한 검사가 뒤늦게 일어난다면, 어차피 요청을 수행할 수 있음에도 불구하고, 데이터베이스에 접근하여 자원의 소유주 여부를 확인해야합니다.
자바의 short circuit evaluation에 의해 A || B라는 조건식이 있을 경우, A가 참이라면 B는 수행되지 않습니다.
즉, 관리자인지 이미 확인됐다면, 데이터베이스 접근을 안해도 된다는 것입니다.
없는 게시글 요청 상황에 대해 간단히 언급하면,
만약 관리자가 없는 게시글 삭제를 요청했다면, 컨트롤러까지 요청은 전달되고, 404 응답을 내려받게 될 것입니다.
만약 일반 사용자가 없는 게시글 삭제를 요청했다면, AccessDeniedHandler가 작동하면서 403 응답을 내려받게 될 것입니다.
* 여기서 작성된 AccessDeniedException은, 우리가 직접 정의했던 것이 아니라 Spring Security에 정의되어있는 예외입니다.
* 만약 Guard를 이용하여 제어하던 방식이 잘 기억나지 않는다면,
2021.12.02 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (8) - 로그인 - 5 - 인증 및 인가 - 1
위 링크를 다시 참고하시길 바랍니다.
이제 새롭게 작성된 API를 테스트해봅시다.
PostControllerTest, PostControllerAdviceTest에 다음 테스트를 추가해주겠습니다.
// PostControllerTest.java
@Test
void deleteTest() throws Exception {
// given
Long id = 1L;
// when, then
mockMvc.perform(
delete("/api/posts/{id}", id))
.andExpect(status().isOk());
verify(postService).delete(id);
}
// PostControllerAdviceTest.java
@Test
void deleteExceptionByPostNotFoundTest() throws Exception {
// given
doThrow(PostNotFoundException.class).when(postService).delete(anyLong());
// when, then
mockMvc.perform(
delete("/api/posts/{id}", 1L))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(-1012));
}
자세한 설명은 생략하겠습니다.
PostControllerIntegrationTest에도 새로운 테스트를 작성해주겠습니다.
...
public class PostControllerIntegrationTest {
...
Member member1, member2, admin;
Category category;
@BeforeEach
void beforeEach() {
mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
initDB.initDB();
member1 = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
member2 = memberRepository.findByEmail(initDB.getMember2Email()).orElseThrow(MemberNotFoundException::new);
admin = memberRepository.findByEmail(initDB.getAdminEmail()).orElseThrow(MemberNotFoundException::new);
category = categoryRepository.findAll().get(0);
}
...
@Test
void deleteByResourceOwnerTest() throws Exception {
// given
Post post = postRepository.save(createPost(member1, category));
SignInResponse signInRes = signService.signIn(createSignInRequest(member1.getEmail(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/posts/{id}", post.getId())
.header("Authorization", signInRes.getAccessToken()))
.andExpect(status().isOk());
assertThatThrownBy(() -> postService.read(post.getId())).isInstanceOf(PostNotFoundException.class);
}
@Test
void deleteByAdminTest() throws Exception {
// given
Post post = postRepository.save(createPost(member1, category));
SignInResponse adminSignInRes = signService.signIn(createSignInRequest(admin.getEmail(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/posts/{id}", post.getId())
.header("Authorization", adminSignInRes.getAccessToken()))
.andExpect(status().isOk());
assertThatThrownBy(() -> postService.read(post.getId())).isInstanceOf(PostNotFoundException.class);
}
@Test
void deleteAccessDeniedByNotResourceOwnerTest() throws Exception {
// given
Post post = postRepository.save(createPost(member1, category));
SignInResponse notOwnerSignInRes = signService.signIn(createSignInRequest(member2.getEmail(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/posts/{id}", post.getId())
.header("Authorization", notOwnerSignInRes.getAccessToken()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/access-denied"));
}
@Test
void deleteUnauthorizedByNoneTokenTest() throws Exception {
// given
Post post = postRepository.save(createPost(member1, category));
// when, then
mockMvc.perform(
delete("/api/posts/{id}", post.getId()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/entry-point"));
}
}
원활한 테스트 진행을 위해, 일반 사용자 2명과 관리자 1명을 초기화해주었습니다.
- 일반 사용자에 의한 삭제 테스트
- 관리자에 의한 삭제 테스트
- 작성자가 아닌 사용자에 의한 삭제 실패 테스트
- 인증되지 않은 사용자에 대한 삭제 실패 테스트
위와 같은 네 개의 테스트가 추가되었습니다.
자세한 설명은 생략하겠습니다.
모든 테스트가 깔끔하게 통과되었습니다.
이제 포스트맨을 이용하여 직접 한 번 테스트해보겠습니다.
이미지와 함께 게시글을 등록하고, 정상적으로 삭제되는지 확인할 것입니다.
2건의 이미지와 함께 게시글을 작성해주었습니다.
삭제 요청을 전송해보겠습니다.
파일 또한 깨끗하게 제거되었습니다.
다음 시간에는 게시글 수정 API를 작성해보도록 하겠습니다.
* 질문 및 피드백은 환영입니다.
* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.
'Spring > 게시판 만들기' 카테고리의 다른 글
스프링부트 게시판 API 서버 만들기 (25) - 게시글 목록 조회 - 페이징 및 검색 조건 (0) | 2021.12.15 |
---|---|
스프링부트 게시판 API 서버 만들기 (24) - 게시글 - 수정 (0) | 2021.12.14 |
스프링부트 게시판 API 서버 만들기 (22) - 게시글 - 조회 (4) | 2021.12.12 |
스프링부트 게시판 API 서버 만들기 (21) - 게시글 - 생성 (4) | 2021.12.12 |
스프링부트 게시판 API 서버 만들기 (20) - 게시글 - 엔티티 설계 (0) | 2021.12.10 |