From 20cfaa116e4dd46e1adcd3086f51051b3790c266 Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Fri, 26 Jul 2024 20:04:20 -0500 Subject: [PATCH] All ImageControllerTests passing. --- .../api/image/ImageControllerTests.java | 155 ++++++++++++++++-- .../api/image/ImageController.java | 27 +++ .../api/image/S3ImageService.java | 40 +++-- .../api/image/spec/ImageCreateInfoSpec.java | 6 +- .../api/image/spec/ImageUpdateInfoSpec.java | 6 +- .../api/image/view/ImageExceptionView.java | 6 + .../api/image/view/ImageView.java | 10 ++ .../api/util/AccessDeniedView.java | 21 +++ 8 files changed, 238 insertions(+), 33 deletions(-) create mode 100644 src/main/java/app/mealsmadeeasy/api/image/view/ImageExceptionView.java create mode 100644 src/main/java/app/mealsmadeeasy/api/util/AccessDeniedView.java diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java index ebea18a..cd13572 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java @@ -2,15 +2,18 @@ package app.mealsmadeeasy.api.image; import app.mealsmadeeasy.api.auth.AuthService; import app.mealsmadeeasy.api.auth.LoginException; +import app.mealsmadeeasy.api.image.body.ImageUpdateInfoBody; import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec; import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserCreateException; import app.mealsmadeeasy.api.user.UserService; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.DynamicPropertyRegistry; @@ -24,11 +27,12 @@ import org.testcontainers.utility.DockerImageName; import java.io.IOException; import java.io.InputStream; import java.util.Set; +import java.util.stream.Collectors; -import static org.hamcrest.CoreMatchers.nullValue; -import static org.junit.jupiter.api.Assertions.fail; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.Matchers.empty; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @Testcontainers @@ -66,6 +70,9 @@ public class ImageControllerTests { @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + private User createTestUser(String username) { try { return this.userService.createUser(username, username + "@test.com", "test"); @@ -183,62 +190,178 @@ public class ImageControllerTests { .andExpect(jsonPath("$.caption").value("HAL 9000, from 2001: A Space Odyssey")) .andExpect(jsonPath("$.isPublic").value(true)) .andExpect(jsonPath("$.owner.username").value("imageOwner")) - .andExpect(jsonPath("$.owner.id").value(owner.getId())); + .andExpect(jsonPath("$.owner.id").value(owner.getId())) + .andExpect(jsonPath("$.viewers").value(empty())); } } + private String prepUpdate() throws ImageException, IOException { + final User owner = this.createTestUser("imageOwner"); + this.createHal9000(owner); + return this.getAccessToken(owner.getUsername()); + } + @Test @DirtiesContext public void updateAlt() throws Exception { - fail("TODO"); + final String accessToken = this.prepUpdate(); + final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); + body.setAlt("HAL 9000"); + this.mockMvc.perform( + post("/images/imageOwner/HAL9000.svg") + .contentType(MediaType.APPLICATION_JSON) + .content(this.objectMapper.writeValueAsString(body)) + .header("Authorization", "Bearer " + accessToken) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.modified").value(notNullValue())) + .andExpect(jsonPath("$.alt").value("HAL 9000")); } @Test @DirtiesContext public void updateCaption() throws Exception { - fail("TODO"); + final String accessToken = this.prepUpdate(); + final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); + body.setCaption("HAL 9000 from 2001: A Space Odyssey"); + this.mockMvc.perform( + post("/images/imageOwner/HAL9000.svg") + .contentType(MediaType.APPLICATION_JSON) + .content(this.objectMapper.writeValueAsString(body)) + .header("Authorization", "Bearer " + accessToken) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.modified").value(notNullValue())) + .andExpect(jsonPath("$.caption").value("HAL 9000 from 2001: A Space Odyssey")); } @Test @DirtiesContext public void updateIsPublic() throws Exception { - fail("TODO"); + final String accessToken = this.prepUpdate(); + final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); + body.setPublic(true); + this.mockMvc.perform( + post("/images/imageOwner/HAL9000.svg") + .contentType(MediaType.APPLICATION_JSON) + .content(this.objectMapper.writeValueAsString(body)) + .header("Authorization", "Bearer " + accessToken) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.modified").value(notNullValue())) + .andExpect(jsonPath("$.isPublic").value(true)); } @Test @DirtiesContext public void addViewers() throws Exception { - fail("TODO"); + final String accessToken = this.prepUpdate(); + final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); + final Set viewerUsernames = Set.of(this.createTestUser("imageViewer")).stream() + .map(User::getUsername) + .collect(Collectors.toSet()); + body.setViewersToAdd(viewerUsernames); + this.mockMvc.perform( + post("/images/imageOwner/HAL9000.svg") + .contentType(MediaType.APPLICATION_JSON) + .content(this.objectMapper.writeValueAsString(body)) + .header("Authorization", "Bearer " + accessToken) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.modified").value(notNullValue())) + .andExpect(jsonPath("$.viewers").value(not(empty()))) + .andExpect(jsonPath("$.viewers[0].username").value("imageViewer")); + } + + private record OwnerViewerImage(User owner, User viewer, Image image) {} + + private OwnerViewerImage prepOwnerViewerImage() throws ImageException, IOException { + final User owner = this.createTestUser("imageOwner"); + final User viewer = this.createTestUser("imageViewer"); + final Image image = this.createHal9000(owner); + final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec(); + spec.setViewersToAdd(Set.of(viewer)); + this.imageService.update(image, owner, spec); + return new OwnerViewerImage(owner, viewer, image); } @Test @DirtiesContext public void removeViewers() throws Exception { - fail("TODO"); + final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage(); + final String accessToken = this.getAccessToken(ownerViewerImage.owner().getUsername()); + final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); + body.setViewersToRemove(Set.of(ownerViewerImage.viewer().getUsername())); + this.mockMvc.perform( + post("/images/imageOwner/HAL9000.svg") + .contentType(MediaType.APPLICATION_JSON) + .content(this.objectMapper.writeValueAsString(body)) + .header("Authorization", "Bearer " + accessToken) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.modified").value(notNullValue())) + .andExpect(jsonPath("$.viewers").value(empty())); } @Test @DirtiesContext public void clearAllViewers() throws Exception { - fail("TODO"); + final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage(); + final String accessToken = this.getAccessToken(ownerViewerImage.owner().getUsername()); + final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); + body.setClearAllViewers(true); + this.mockMvc.perform( + post("/images/imageOwner/HAL9000.svg") + .contentType(MediaType.APPLICATION_JSON) + .content(this.objectMapper.writeValueAsString(body)) + .header("Authorization", "Bearer " + accessToken) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.modified").value(notNullValue())) + .andExpect(jsonPath("$.viewers").value(empty())); } @Test @DirtiesContext - public void updateInfoWithViewerFails() throws Exception { - fail("TODO"); + public void updateInfoByViewerForbidden() throws Exception { + final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage(); + final String accessToken = this.getAccessToken(ownerViewerImage.viewer().getUsername()); // viewer + final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); + this.mockMvc.perform( + post("/images/imageOwner/HAL9000.svg") + .contentType(MediaType.APPLICATION_JSON ) + .content(this.objectMapper.writeValueAsString(body)) + .header("Authorization", "Bearer " + accessToken) + ) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.statusCode").value(403)) + .andExpect(jsonPath("$.message").value(notNullValue())); } @Test @DirtiesContext public void deleteImageWithOwner() throws Exception { - fail("TODO"); + final User owner = this.createTestUser("imageOwner"); + final Image image = this.createHal9000(owner); + final String accessToken = this.getAccessToken(owner.getUsername()); + this.mockMvc.perform( + delete("/images/imageOwner/HAL9000.svg") + .header("Authorization", "Bearer " + accessToken) + ) + .andExpect(status().isNoContent()); + assertThrows(ImageException.class, () -> this.imageService.getById(image.getId(), owner)); } @Test @DirtiesContext - public void deleteImageWithViewer() throws Exception { - fail("TODO"); + public void deleteImageByViewerForbidden() throws Exception { + final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage(); + final String accessToken = this.getAccessToken(ownerViewerImage.viewer().getUsername()); + this.mockMvc.perform( + delete("/images/imageOwner/HAL9000.svg") + .header("Authorization", "Bearer " + accessToken) + ) + .andExpect(status().isForbidden()); } } diff --git a/src/main/java/app/mealsmadeeasy/api/image/ImageController.java b/src/main/java/app/mealsmadeeasy/api/image/ImageController.java index bd92577..9807d82 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/ImageController.java +++ b/src/main/java/app/mealsmadeeasy/api/image/ImageController.java @@ -7,16 +7,21 @@ import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserService; import app.mealsmadeeasy.api.user.view.UserInfoView; +import app.mealsmadeeasy.api.util.AccessDeniedView; import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.io.InputStream; +import java.util.HashSet; import java.util.Set; import java.util.stream.Collectors; @@ -39,6 +44,15 @@ public class ImageController { userInfoView.setUsername(owner.getUsername()); imageView.setOwner(userInfoView); + final Set viewers = new HashSet<>(); + for (final User viewer : image.getViewers()) { + final UserInfoView viewerView = new UserInfoView(); + viewerView.setId(viewer.getId()); + viewerView.setUsername(viewer.getUsername()); + viewers.add(viewerView); + } + imageView.setViewers(viewers); + return imageView; } @@ -73,6 +87,19 @@ public class ImageController { return spec; } + @ExceptionHandler + public ResponseEntity onAccessDenied(AccessDeniedException e) { + if (e instanceof AuthorizationDeniedException) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .contentType(MediaType.APPLICATION_JSON) + .body(new AccessDeniedView(HttpStatus.FORBIDDEN.value(), e.getMessage())); + } else { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .contentType(MediaType.APPLICATION_JSON) + .body(new AccessDeniedView(HttpStatus.UNAUTHORIZED.value(), e.getMessage())); + } + } + @GetMapping("/{username}/{filename}") public ResponseEntity getImage( @AuthenticationPrincipal User principal, diff --git a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java index 3b83913..e5c69ec 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java +++ b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Service; import java.io.IOException; import java.io.InputStream; +import java.time.LocalDateTime; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -64,21 +65,30 @@ public class S3ImageService implements ImageService { }; } - private void transferFromSpec(S3ImageEntity entity, ImageCreateInfoSpec spec) { + private boolean transferFromSpec(S3ImageEntity entity, ImageCreateInfoSpec spec) { + boolean didTransfer = false; if (spec.getAlt() != null) { entity.setAlt(spec.getAlt()); + didTransfer = true; } if (spec.getCaption() != null) { entity.setCaption(spec.getCaption()); + didTransfer = true; } if (spec.getPublic() != null) { entity.setPublic(spec.getPublic()); + didTransfer = true; } - final Set viewers = new HashSet<>(entity.getViewerEntities()); - for (final User viewerToAdd : spec.getViewersToAdd()) { - viewers.add((UserEntity) viewerToAdd); + final @Nullable Set viewersToAdd = spec.getViewersToAdd(); + if (viewersToAdd != null) { + final Set viewers = new HashSet<>(entity.getViewerEntities()); + for (final User viewerToAdd : spec.getViewersToAdd()) { + viewers.add((UserEntity) viewerToAdd); + } + entity.setViewers(viewers); + didTransfer = true; } - entity.setViewers(viewers); + return didTransfer; } @Override @@ -139,16 +149,24 @@ public class S3ImageService implements ImageService { @PreAuthorize("@imageSecurity.isOwner(#image, #modifier)") public Image update(final Image image, User modifier, ImageUpdateInfoSpec updateSpec) { S3ImageEntity entity = (S3ImageEntity) image; - this.transferFromSpec(entity, updateSpec); + boolean didUpdate = this.transferFromSpec(entity, updateSpec); final @Nullable Boolean clearAllViewers = updateSpec.getClearAllViewers(); if (clearAllViewers != null && clearAllViewers) { - entity.setViewers(Set.of()); + entity.setViewers(new HashSet<>()); + didUpdate = true; } else { - final Set viewers = new HashSet<>(entity.getViewerEntities()); - for (final User toRemove : updateSpec.getViewersToRemove()) { - viewers.remove((UserEntity) toRemove); + final @Nullable Set viewersToRemove = updateSpec.getViewersToRemove(); + if (viewersToRemove != null) { + final Set currentViewers = new HashSet<>(entity.getViewerEntities()); + for (final User toRemove : updateSpec.getViewersToRemove()) { + currentViewers.remove((UserEntity) toRemove); + } + entity.setViewers(currentViewers); + didUpdate = true; } - entity.setViewers(viewers); + } + if (didUpdate) { + entity.setModified(LocalDateTime.now()); } return this.imageRepository.save(entity); } diff --git a/src/main/java/app/mealsmadeeasy/api/image/spec/ImageCreateInfoSpec.java b/src/main/java/app/mealsmadeeasy/api/image/spec/ImageCreateInfoSpec.java index 69c363c..dccd197 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/spec/ImageCreateInfoSpec.java +++ b/src/main/java/app/mealsmadeeasy/api/image/spec/ImageCreateInfoSpec.java @@ -11,7 +11,7 @@ public class ImageCreateInfoSpec { private @Nullable String alt; private @Nullable String caption; private @Nullable Boolean isPublic; - private Set viewersToAdd = new HashSet<>(); + private @Nullable Set viewersToAdd = new HashSet<>(); public @Nullable String getAlt() { return this.alt; @@ -37,11 +37,11 @@ public class ImageCreateInfoSpec { isPublic = aPublic; } - public Set getViewersToAdd() { + public @Nullable Set getViewersToAdd() { return this.viewersToAdd; } - public void setViewersToAdd(Set viewersToAdd) { + public void setViewersToAdd(@Nullable Set viewersToAdd) { this.viewersToAdd = viewersToAdd; } diff --git a/src/main/java/app/mealsmadeeasy/api/image/spec/ImageUpdateInfoSpec.java b/src/main/java/app/mealsmadeeasy/api/image/spec/ImageUpdateInfoSpec.java index ea0465c..9cd4224 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/spec/ImageUpdateInfoSpec.java +++ b/src/main/java/app/mealsmadeeasy/api/image/spec/ImageUpdateInfoSpec.java @@ -8,14 +8,14 @@ import java.util.Set; public class ImageUpdateInfoSpec extends ImageCreateInfoSpec { - private Set viewersToRemove = new HashSet<>(); + private @Nullable Set viewersToRemove; private @Nullable Boolean clearAllViewers; - public Set getViewersToRemove() { + public @Nullable Set getViewersToRemove() { return this.viewersToRemove; } - public void setViewersToRemove(Set viewersToRemove) { + public void setViewersToRemove(@Nullable Set viewersToRemove) { this.viewersToRemove = viewersToRemove; } diff --git a/src/main/java/app/mealsmadeeasy/api/image/view/ImageExceptionView.java b/src/main/java/app/mealsmadeeasy/api/image/view/ImageExceptionView.java new file mode 100644 index 0000000..c252964 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/image/view/ImageExceptionView.java @@ -0,0 +1,6 @@ +package app.mealsmadeeasy.api.image.view; + +public class ImageExceptionView { + + +} diff --git a/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java b/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java index b9770c8..2c8dd10 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java +++ b/src/main/java/app/mealsmadeeasy/api/image/view/ImageView.java @@ -4,6 +4,7 @@ import app.mealsmadeeasy.api.user.view.UserInfoView; import org.jetbrains.annotations.Nullable; import java.time.LocalDateTime; +import java.util.Set; public class ImageView { @@ -15,6 +16,7 @@ public class ImageView { private @Nullable String caption; private UserInfoView owner; private boolean isPublic; + private Set viewers; public LocalDateTime getCreated() { return this.created; @@ -80,4 +82,12 @@ public class ImageView { this.isPublic = isPublic; } + public Set getViewers() { + return this.viewers; + } + + public void setViewers(Set viewers) { + this.viewers = viewers; + } + } diff --git a/src/main/java/app/mealsmadeeasy/api/util/AccessDeniedView.java b/src/main/java/app/mealsmadeeasy/api/util/AccessDeniedView.java new file mode 100644 index 0000000..8241de7 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/util/AccessDeniedView.java @@ -0,0 +1,21 @@ +package app.mealsmadeeasy.api.util; + +public final class AccessDeniedView { + + private final int statusCode; + private final String message; + + public AccessDeniedView(int statusCode, String message) { + this.statusCode = statusCode; + this.message = message; + } + + public int getStatusCode() { + return this.statusCode; + } + + public String getMessage() { + return this.message; + } + +}