package app.mealsmadeeasy.api.image; import app.mealsmadeeasy.api.IntegrationTestsExtension; import app.mealsmadeeasy.api.auth.AuthService; import app.mealsmadeeasy.api.auth.LoginException; import app.mealsmadeeasy.api.image.body.ImageUpdateBody; import app.mealsmadeeasy.api.image.spec.ImageCreateSpec; import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec; 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.junit.jupiter.api.extension.ExtendWith; 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.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.testcontainers.containers.MinIOContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; import java.io.IOException; import java.io.InputStream; import java.util.Set; import java.util.UUID; 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 @SpringBootTest @AutoConfigureMockMvc @ExtendWith(IntegrationTestsExtension.class) public class ImageControllerTests { @Container private static final MinIOContainer container = new MinIOContainer( DockerImageName.parse("minio/minio:latest") ); @DynamicPropertySource public static void minioProperties(DynamicPropertyRegistry registry) { registry.add("app.mealsmadeeasy.api.minio.endpoint", container::getS3URL); registry.add("app.mealsmadeeasy.api.minio.accessKey", container::getUserName); registry.add("app.mealsmadeeasy.api.minio.secretKey", container::getPassword); } private static InputStream getHal9000InputStream() { return ImageControllerTests.class.getResourceAsStream("HAL9000.svg"); } @Autowired private UserService userService; @Autowired private AuthService authService; @Autowired private ImageService imageService; @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; private static final String TEST_PASSWORD = "test"; private static final long HAL_SIZE = 27881L; private static String makeUserFilename() { return UUID.randomUUID() + ".svg"; } private User seedUser() { final String uuid = UUID.randomUUID().toString(); try { return this.userService.createUser(uuid, uuid + "@test.com", TEST_PASSWORD); } catch (UserCreateException e) { throw new RuntimeException(e); } } private Image seedImage(User owner, ImageCreateSpec spec) { try { return this.imageService.create( owner, makeUserFilename(), getHal9000InputStream(), HAL_SIZE, spec ); } catch (IOException | ImageException e) { throw new RuntimeException(e); } } private Image seedImage(User owner) { return this.seedImage(owner, ImageCreateSpec.builder().build()); } private static String getImageUrl(User owner, Image image) { return "/images/" + owner.getUsername() + "/" + image.getUserFilename(); } private String getAccessToken(User user) { try { return this.authService.login(user.getUsername(), TEST_PASSWORD).getAccessToken().getToken(); } catch (LoginException e) { throw new RuntimeException(e); } } @Test public void getPublicImageNoPrincipal() throws Exception { final User owner = this.seedUser(); final ImageCreateSpec spec = ImageCreateSpec.builder() .isPublic(true) .build(); final Image image = this.seedImage(owner, spec); // Assert bytes the same and proper mime type try (final InputStream hal9000 = getHal9000InputStream()) { final byte[] halBytes = hal9000.readAllBytes(); this.mockMvc.perform(get(getImageUrl(owner, image))) .andExpect(status().isOk()) .andExpect(content().contentType("image/svg+xml")) .andExpect(content().bytes(halBytes)); } } private void doGetImageTestWithAccessToken(User owner, Image image, String accessToken) throws Exception { try (final InputStream hal9000 = getHal9000InputStream()) { final byte[] halBytes = hal9000.readAllBytes(); this.mockMvc.perform(get(getImageUrl(owner, image)) .header("Authorization", "Bearer " + accessToken)) .andExpect(status().isOk()) .andExpect(content().contentType("image/svg+xml")) .andExpect(content().bytes(halBytes)); } } @Test public void getImageForOwner() throws Exception { final User owner = this.seedUser(); final Image image = this.seedImage(owner); this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(owner)); } @Test public void getImageForViewer() throws Exception { final User owner = this.seedUser(); final User viewer = this.seedUser(); final Image image = this.seedImage(owner); // add viewer final ImageUpdateSpec spec = ImageUpdateSpec.builder() .viewersToAdd(Set.of(viewer)) .build(); this.imageService.update(image, owner, spec); this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(viewer)); } @Test public void getNonPublicImageNoPrincipalForbidden() throws Exception { final User owner = this.seedUser(); final Image image = this.seedImage(owner); this.mockMvc.perform(get(getImageUrl(owner, image))) .andExpect(status().isForbidden()); } @Test public void getNonPublicImageWithPrincipalForbidden() throws Exception { final User owner = this.seedUser(); final Image image = this.seedImage(owner); final User nonViewer = this.seedUser(); final String nonViewerToken = this.getAccessToken(nonViewer); this.mockMvc.perform( get(getImageUrl(owner, image)) .header("Authorization", "Bearer " + nonViewerToken) ).andExpect(status().isForbidden()); } @Test public void getImageWithViewersNoPrincipalForbidden() throws Exception { final User owner = this.seedUser(); final Image image = this.seedImage(owner); final User viewer = this.seedUser(); // add viewer final ImageUpdateSpec spec = ImageUpdateSpec.builder() .viewersToAdd(Set.of(viewer)) .build(); this.imageService.update(image, owner, spec); this.mockMvc.perform(get(getImageUrl(owner, image))).andExpect(status().isForbidden()); } @Test public void putImage() throws Exception { final User owner = this.seedUser(); final String accessToken = this.getAccessToken(owner); try (final InputStream hal9000 = getHal9000InputStream()) { final String userFilename = makeUserFilename(); final MockMultipartFile mockMultipartFile = new MockMultipartFile( "image", userFilename, "image/svg+xml", hal9000 ); this.mockMvc.perform( multipart("/images") .file(mockMultipartFile) .param("filename", userFilename) .param("alt", "HAL 9000") .param("caption", "HAL 9000, from 2001: A Space Odyssey") .param("isPublic", "true") .header("Authorization", "Bearer " + accessToken) .with(req -> { req.setMethod("PUT"); return req; }) ) .andExpect(status().isCreated()) .andExpect(jsonPath("$.created").exists()) .andExpect(jsonPath("$.modified").value(nullValue())) .andExpect(jsonPath("$.filename").value(userFilename)) .andExpect(jsonPath("$.mimeType").value("image/svg+xml")) .andExpect(jsonPath("$.alt").value("HAL 9000")) .andExpect(jsonPath("$.caption").value("HAL 9000, from 2001: A Space Odyssey")) .andExpect(jsonPath("$.public").value(true)) .andExpect(jsonPath("$.owner.username").value(owner.getUsername())) .andExpect(jsonPath("$.owner.id").value(owner.getId())) .andExpect(jsonPath("$.viewers").value(empty())); } } @Test public void updateAlt() throws Exception { final User owner = this.seedUser(); final Image image = this.seedImage(owner); final String accessToken = this.getAccessToken(owner); final ImageUpdateBody body = new ImageUpdateBody(); body.setAlt("HAL 9000"); this.mockMvc.perform( post(getImageUrl(owner, image)) .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 public void updateCaption() throws Exception { final User owner = this.seedUser(); final Image image = this.seedImage(owner); final String accessToken = this.getAccessToken(owner); final ImageUpdateBody body = new ImageUpdateBody(); body.setCaption("HAL 9000 from 2001: A Space Odyssey"); this.mockMvc.perform( post(getImageUrl(owner, image)) .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 public void updateIsPublic() throws Exception { final User owner = this.seedUser(); final Image image = this.seedImage(owner); final String accessToken = this.getAccessToken(owner); final ImageUpdateBody body = new ImageUpdateBody(); body.setIsPublic(true); this.mockMvc.perform( post(getImageUrl(owner, image)) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.modified").value(notNullValue())) .andExpect(jsonPath("$.public").value(true)); } @Test public void addViewers() throws Exception { final User owner = this.seedUser(); final User viewerToAdd = this.seedUser(); final Image image = this.seedImage(owner); final String accessToken = this.getAccessToken(owner); final ImageUpdateBody body = new ImageUpdateBody(); final Set viewerUsernames = Set.of(viewerToAdd.getUsername()); body.setViewersToAdd(viewerUsernames); this.mockMvc.perform( post(getImageUrl(owner, image)) .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(viewerToAdd.getUsername())); } private record OwnerViewerImage(User owner, User viewer, Image image) {} private OwnerViewerImage prepOwnerViewerImage() { final User owner = this.seedUser(); final User viewer = this.seedUser(); final Image image = this.seedImage(owner); final ImageUpdateSpec spec = ImageUpdateSpec.builder() .viewersToAdd(Set.of(viewer)) .build(); this.imageService.update(image, owner, spec); return new OwnerViewerImage(owner, viewer, image); } @Test public void removeViewers() throws Exception { final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage(); final String accessToken = this.getAccessToken(ownerViewerImage.owner()); final ImageUpdateBody body = new ImageUpdateBody(); body.setViewersToRemove(Set.of(ownerViewerImage.viewer().getUsername())); this.mockMvc.perform( post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) .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 public void clearAllViewers() throws Exception { final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage(); final String accessToken = this.getAccessToken(ownerViewerImage.owner()); final ImageUpdateBody body = new ImageUpdateBody(); body.setClearAllViewers(true); this.mockMvc.perform( post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) .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 public void updateInfoByViewerForbidden() throws Exception { final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage(); final String accessToken = this.getAccessToken(ownerViewerImage.viewer()); // viewer final ImageUpdateBody body = new ImageUpdateBody(); this.mockMvc.perform( post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) .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 public void deleteImageWithOwner() throws Exception { final User owner = this.seedUser(); final Image image = this.seedImage(owner); final String accessToken = this.getAccessToken(owner); this.mockMvc.perform( delete(getImageUrl(owner, image)) .header("Authorization", "Bearer " + accessToken) ) .andExpect(status().isNoContent()); assertThrows(ImageException.class, () -> this.imageService.getById(image.getId(), owner)); } @Test public void deleteImageByViewerForbidden() throws Exception { final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage(); final String accessToken = this.getAccessToken(ownerViewerImage.viewer()); this.mockMvc.perform( delete(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) .header("Authorization", "Bearer " + accessToken) ) .andExpect(status().isForbidden()); } }