APIs for profile photos (#3260)

* Add API for uploading profile photos for current user

* Add delete and get APIs for profile photos

* Add test for uploading and deleting profile photo

* Added negative tests for upload profile photo API
This commit is contained in:
Shrikant Sharat Kandula 2021-03-09 17:03:20 +05:30 committed by GitHub
parent 092a942036
commit bb9a9a307f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 408 additions and 62 deletions

View File

@ -4,11 +4,6 @@ import com.appsmith.server.constants.Url;
import com.appsmith.server.services.AssetService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.buffer.DefaultDataBuffer;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@ -26,21 +21,7 @@ public class AssetController {
@GetMapping("/{id}")
public Mono<Void> getById(@PathVariable String id, ServerWebExchange exchange) {
log.debug("Returning asset with ID '{}'.", id);
final ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.OK);
final Mono<DefaultDataBuffer> imageBufferMono = service.getById(id)
.map(asset -> {
final String contentType = asset.getContentType();
if (contentType != null) {
response.getHeaders().set(HttpHeaders.CONTENT_TYPE, contentType);
}
return new DefaultDataBufferFactory().wrap(asset.getData());
});
return response.writeWith(imageBufferMono);
return service.makeImageResponse(exchange, id);
}
}

View File

@ -2,6 +2,7 @@ package com.appsmith.server.controllers;
import com.appsmith.server.constants.Url;
import com.appsmith.server.domains.User;
import com.appsmith.server.domains.UserData;
import com.appsmith.server.dtos.InviteUsersDTO;
import com.appsmith.server.dtos.ResetUserPasswordDTO;
import com.appsmith.server.dtos.ResponseDTO;
@ -14,6 +15,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.Part;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@ -22,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ServerWebExchange;
@ -142,4 +146,34 @@ public class UserController extends BaseController<UserService, User, String> {
.thenReturn(new ResponseDTO<>(HttpStatus.OK.value(), null, null));
}
@PostMapping(value = "/photo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Mono<ResponseDTO<UserData>> uploadProfilePhoto(@RequestPart("file") Mono<Part> fileMono) {
return fileMono
.flatMap(userDataService::saveProfilePhoto)
.map(url -> new ResponseDTO<>(HttpStatus.OK.value(), url, null));
}
@DeleteMapping("/photo")
public Mono<ResponseDTO<Void>> deleteProfilePhoto() {
return userDataService
.deleteProfilePhoto()
.map(ignored -> new ResponseDTO<>(HttpStatus.OK.value(), null, null));
}
@GetMapping("/photo")
public Mono<Void> getProfilePhoto(ServerWebExchange exchange) {
return userDataService.makeProfilePhotoResponse(exchange)
.switchIfEmpty(Mono.fromRunnable(() -> {
exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
}));
}
@GetMapping("/photo/{email}")
public Mono<Void> getProfilePhoto(ServerWebExchange exchange, @PathVariable String email) {
return userDataService.makeProfilePhotoResponse(exchange, email)
.switchIfEmpty(Mono.fromRunnable(() -> {
exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
}));
}
}

View File

@ -3,6 +3,7 @@ package com.appsmith.server.domains;
import com.appsmith.external.models.BaseDomain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.springframework.data.mongodb.core.mapping.Document;
@ -14,11 +15,15 @@ import org.springframework.data.mongodb.core.mapping.Document;
@Setter
@ToString
@Document
@NoArgsConstructor
public class UserData extends BaseDomain {
@JsonIgnore
String userId;
// The ID of the asset which has the profile photo of this user.
private String profilePhotoAssetId;
// The version where this user has last viewed the release notes.
private String releaseNotesViewedVersion;

View File

@ -68,6 +68,12 @@ public class AnalyticsService {
TrackMessage.Builder messageBuilder = TrackMessage.builder(event).userId(userId);
if (!CollectionUtils.isEmpty(properties)) {
// Segment throws an NPE if any value in `properties` is null.
for (final Map.Entry<String, Object> entry : properties.entrySet()) {
if (entry.getValue() == null) {
properties.put(entry.getKey(), "");
}
}
messageBuilder = messageBuilder.properties(properties);
}

View File

@ -1,10 +1,17 @@
package com.appsmith.server.services;
import com.appsmith.server.domains.Asset;
import org.springframework.http.codec.multipart.Part;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
public interface AssetService {
Mono<Asset> getById(String id);
Mono<Asset> upload(Part filePart, int i);
Mono<Void> remove(String assetId);
Mono<Void> makeImageResponse(ServerWebExchange exchange, String assetId);
}

View File

@ -1,12 +1,26 @@
package com.appsmith.server.services;
import com.appsmith.server.domains.Asset;
import com.appsmith.server.exceptions.AppsmithError;
import com.appsmith.server.exceptions.AppsmithException;
import com.appsmith.server.repositories.AssetRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.Part;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Set;
@Slf4j
@Service
@RequiredArgsConstructor
@ -14,9 +28,84 @@ public class AssetServiceImpl implements AssetService {
private final AssetRepository repository;
private final AnalyticsService analyticsService;
private static final Set<MediaType> ALLOWED_CONTENT_TYPES = Set.of(MediaType.IMAGE_JPEG, MediaType.IMAGE_PNG);
@Override
public Mono<Asset> getById(String id) {
return repository.findById(id);
}
@Override
public Mono<Asset> upload(Part filePart, int maxFileSizeKB) {
if (filePart == null) {
return Mono.error(new AppsmithException(AppsmithError.VALIDATION_FAILURE, "Please upload a valid image."));
}
// The reason we restrict file types here is to avoid having to deal with dangerous image types such as SVG,
// which can have arbitrary HTML/JS inside of them.
final MediaType contentType = filePart.headers().getContentType();
if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
return Mono.error(new AppsmithException(
AppsmithError.VALIDATION_FAILURE,
"Please upload a valid image. Only JPEG and PNG are allowed."
));
}
final Flux<DataBuffer> contentCache = filePart.content().cache();
return contentCache.count()
.defaultIfEmpty(0L)
.flatMap(count -> {
// Default implementation for the BufferFactory used breaks down the FilePart into chunks of 4KB.
// So we multiply the count of chunks with 4 to get an estimate on the file size in KB.
if (4 * count > maxFileSizeKB) {
return Mono.error(new AppsmithException(AppsmithError.PAYLOAD_TOO_LARGE, maxFileSizeKB));
}
return DataBufferUtils.join(contentCache);
})
.flatMap(dataBuffer -> {
byte[] data = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(data);
DataBufferUtils.release(dataBuffer);
return repository.save(new Asset(contentType, data));
})
.flatMap(analyticsService::sendCreateEvent);
}
/**
* This function hard-deletes (read: not archive) the asset given by the ID. It is intended to be used to delete an
* old asset when a user uploads a new one. For example, when a new profile photo or an organization logo is,
* uploaded, this method is used to completely delete the old one, if any.
* @param assetId The ID string of the asset to delete.
* @return empty Mono
*/
@Override
public Mono<Void> remove(String assetId) {
final Asset tempAsset = new Asset();
tempAsset.setId(assetId);
return repository.deleteById(assetId)
.then(analyticsService.sendDeleteEvent(tempAsset))
.then();
}
@Override
public Mono<Void> makeImageResponse(ServerWebExchange exchange, String assetId) {
log.debug("Returning asset with ID '{}'.", assetId);
return getById(assetId)
.flatMap(asset -> {
final String contentType = asset.getContentType();
final ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.OK);
if (contentType != null) {
response.getHeaders().set(HttpHeaders.CONTENT_TYPE, contentType);
}
return response.writeWith(Mono.just(new DefaultDataBufferFactory().wrap(asset.getData())));
});
}
}

View File

@ -19,8 +19,6 @@ import com.appsmith.server.repositories.PluginRepository;
import com.appsmith.server.repositories.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
import org.springframework.data.mongodb.core.convert.MongoConverter;
@ -28,6 +26,7 @@ import org.springframework.http.codec.multipart.Part;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
@ -55,6 +54,7 @@ public class OrganizationServiceImpl extends BaseService<OrganizationRepository,
private final UserRepository userRepository;
private final RoleGraph roleGraph;
private final AssetRepository assetRepository;
private final AssetService assetService;
@Autowired
public OrganizationServiceImpl(Scheduler scheduler,
@ -68,7 +68,8 @@ public class OrganizationServiceImpl extends BaseService<OrganizationRepository,
UserOrganizationService userOrganizationService,
UserRepository userRepository,
RoleGraph roleGraph,
AssetRepository assetRepository) {
AssetRepository assetRepository,
AssetService assetService) {
super(scheduler, validator, mongoConverter, reactiveMongoTemplate, repository, analyticsService);
this.pluginRepository = pluginRepository;
this.sessionUserService = sessionUserService;
@ -76,6 +77,7 @@ public class OrganizationServiceImpl extends BaseService<OrganizationRepository,
this.userRepository = userRepository;
this.roleGraph = roleGraph;
this.assetRepository = assetRepository;
this.assetService = assetService;
}
@Override
@ -290,50 +292,26 @@ public class OrganizationServiceImpl extends BaseService<OrganizationRepository,
@Override
public Mono<Organization> uploadLogo(String organizationId, Part filePart) {
return repository
.findById(organizationId, MANAGE_ORGANIZATIONS)
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.ORGANIZATION, organizationId)))
.flatMap(organization -> {
if (filePart != null && filePart.headers().getContentType() != null) {
// Default implementation for the BufferFactory used breaks down the FilePart into chunks of 4KB
// To limit file size to 250KB, we only allow 63 (250/4 = 62.5) such chunks to be derived from the incoming FilePart
return filePart.content().count().flatMap(count -> {
if (count > (int) Math.ceil(Constraint.ORGANIZATION_LOGO_SIZE_KB / 4.0)) {
return Mono.error(new AppsmithException(AppsmithError.PAYLOAD_TOO_LARGE, Constraint.ORGANIZATION_LOGO_SIZE_KB));
} else {
return Mono.zip(Mono.just(organization), DataBufferUtils.join(filePart.content()));
}
});
} else {
return Mono.error(new AppsmithException(AppsmithError.VALIDATION_FAILURE, "Please upload a valid image."));
}
})
final Mono<Organization> findOrganizationMono = repository.findById(organizationId, MANAGE_ORGANIZATIONS)
.switchIfEmpty(Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, FieldName.ORGANIZATION, organizationId)));
// We don't execute the upload Mono if we don't find the organization.
final Mono<Asset> uploadAssetMono = assetService.upload(filePart, Constraint.ORGANIZATION_LOGO_SIZE_KB);
return findOrganizationMono
.flatMap(organization -> Mono.zip(Mono.just(organization), uploadAssetMono))
.flatMap(tuple -> {
final Organization organization = tuple.getT1();
final DataBuffer dataBuffer = tuple.getT2();
final Asset uploadedAsset = tuple.getT2();
final String prevAssetId = organization.getLogoAssetId();
byte[] data = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(data);
DataBufferUtils.release(dataBuffer);
return assetRepository
.save(new Asset(filePart.headers().getContentType(), data))
.flatMap(asset -> {
organization.setLogoAssetId(asset.getId());
Mono<Organization> savedOrganization = repository.save(organization);
Mono<Asset> createdAsset = analyticsService.sendCreateEvent(asset);
return savedOrganization.zipWith(createdAsset);
})
.flatMap(savedTuple -> {
Organization savedOrganization = savedTuple.getT1();
if (prevAssetId != null) {
return assetRepository.findById(prevAssetId)
.flatMap(asset -> assetRepository.delete(asset).thenReturn(asset))
.flatMap(analyticsService::sendDeleteEvent)
.thenReturn(savedOrganization);
} else {
organization.setLogoAssetId(uploadedAsset.getId());
return repository.save(organization)
.flatMap(savedOrganization -> {
if (StringUtils.isEmpty(prevAssetId)) {
return Mono.just(savedOrganization);
} else {
return assetService.remove(prevAssetId).thenReturn(savedOrganization);
}
});
});

View File

@ -2,6 +2,8 @@ package com.appsmith.server.services;
import com.appsmith.server.domains.User;
import com.appsmith.server.domains.UserData;
import org.springframework.http.codec.multipart.Part;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
public interface UserDataService {
@ -9,9 +11,24 @@ public interface UserDataService {
Mono<UserData> getForUser(String userId);
Mono<UserData> getForCurrentUser();
Mono<UserData> getForUserEmail(String email);
Mono<UserData> updateForCurrentUser(UserData updates);
Mono<User> setViewedCurrentVersionReleaseNotes(User user);
Mono<User> setViewedCurrentVersionReleaseNotes(User user, String version);
Mono<User> ensureViewedCurrentVersionReleaseNotes(User user);
Mono<UserData> saveProfilePhoto(Part filePart);
Mono<Void> deleteProfilePhoto();
Mono<Void> makeProfilePhotoResponse(ServerWebExchange exchange, String email);
Mono<Void> makeProfilePhotoResponse(ServerWebExchange exchange);
}

View File

@ -1,26 +1,46 @@
package com.appsmith.server.services;
import com.appsmith.server.domains.Asset;
import com.appsmith.server.domains.QUserData;
import com.appsmith.server.domains.User;
import com.appsmith.server.domains.UserData;
import com.appsmith.server.exceptions.AppsmithError;
import com.appsmith.server.exceptions.AppsmithException;
import com.appsmith.server.repositories.UserDataRepository;
import com.appsmith.server.solutions.ReleaseNotesService;
import com.mongodb.DBObject;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
import org.springframework.data.mongodb.core.convert.MongoConverter;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.http.codec.multipart.Part;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import javax.validation.Validator;
import java.util.Map;
import static com.appsmith.server.repositories.BaseAppsmithRepositoryImpl.fieldName;
@Service
public class UserDataServiceImpl extends BaseService<UserDataRepository, UserData, String> implements UserDataService {
private final UserService userService;
private final SessionUserService sessionUserService;
private final AssetService assetService;
private final ReleaseNotesService releaseNotesService;
private static final int MAX_PROFILE_PHOTO_SIZE_KB = 250;
@Autowired
public UserDataServiceImpl(Scheduler scheduler,
Validator validator,
@ -29,11 +49,15 @@ public class UserDataServiceImpl extends BaseService<UserDataRepository, UserDat
UserDataRepository repository,
AnalyticsService analyticsService,
UserService userService,
SessionUserService sessionUserService,
AssetService assetService,
ReleaseNotesService releaseNotesService
) {
super(scheduler, validator, mongoConverter, reactiveMongoTemplate, repository, analyticsService);
this.userService = userService;
this.releaseNotesService = releaseNotesService;
this.assetService = assetService;
this.sessionUserService = sessionUserService;
}
@Override
@ -50,6 +74,57 @@ public class UserDataServiceImpl extends BaseService<UserDataRepository, UserDat
: repository.findByUserId(userId).defaultIfEmpty(new UserData(userId));
}
@Override
public Mono<UserData> getForCurrentUser() {
return sessionUserService.getCurrentUser()
.map(User::getEmail)
.flatMap(this::getForUserEmail);
}
@Override
public Mono<UserData> getForUserEmail(String email) {
return userService.findByEmail(email)
.flatMap(this::getForUser);
}
@Override
public Mono<UserData> updateForCurrentUser(UserData updates) {
return sessionUserService.getCurrentUser()
.flatMap(user -> userService.findByEmail(user.getEmail()))
.flatMap(user -> {
// If a UserData document exists for this user, update it. If not, create one.
updates.setUserId(user.getId());
final Mono<UserData> updaterMono = update(user.getId(), updates);
final Mono<UserData> creatorMono = Mono.just(updates).flatMap(this::create);
return updaterMono.switchIfEmpty(creatorMono);
});
}
@Override
public Mono<UserData> update(String userId, UserData resource) {
if (userId == null) {
return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, fieldName(QUserData.userData.userId)));
}
Query query = new Query(Criteria.where(fieldName(QUserData.userData.userId)).is(userId));
// In case the update is not used to update the policies, then set the policies to null to ensure that the
// existing policies are not overwritten.
if (resource.getPolicies().isEmpty()) {
resource.setPolicies(null);
}
DBObject update = getDbObject(resource);
Update updateObj = new Update();
Map<String, Object> updateMap = update.toMap();
updateMap.entrySet().stream().forEach(entry -> updateObj.set(entry.getKey(), entry.getValue()));
return mongoTemplate.updateFirst(query, updateObj, resource.getClass())
.flatMap(updateResult -> updateResult.getMatchedCount() == 0 ? Mono.empty() : repository.findByUserId(userId))
.flatMap(analyticsService::sendUpdateEvent);
}
@Override
public Mono<User> setViewedCurrentVersionReleaseNotes(User user) {
final String version = releaseNotesService.getReleasedVersion();
@ -86,4 +161,50 @@ public class UserDataServiceImpl extends BaseService<UserDataRepository, UserDat
});
}
@Override
public Mono<UserData> saveProfilePhoto(Part filePart) {
final Mono<String> prevAssetIdMono = getForCurrentUser()
.map(userData -> ObjectUtils.defaultIfNull(userData.getProfilePhotoAssetId(), ""));
final Mono<Asset> uploaderMono = assetService.upload(filePart, MAX_PROFILE_PHOTO_SIZE_KB);
return Mono.zip(prevAssetIdMono, uploaderMono)
.flatMap(tuple -> {
final String oldAssetId = tuple.getT1();
final Asset uploadedAsset = tuple.getT2();
final UserData updates = new UserData();
updates.setProfilePhotoAssetId(uploadedAsset.getId());
final Mono<UserData> updateMono = updateForCurrentUser(updates);
if (StringUtils.isEmpty(oldAssetId)) {
return updateMono;
} else {
return assetService.remove(oldAssetId).then(updateMono);
}
});
}
@Override
public Mono<Void> deleteProfilePhoto() {
return getForCurrentUser()
.flatMap(userData -> Mono.justOrEmpty(userData.getProfilePhotoAssetId()))
.flatMap(assetService::remove);
}
@Override
public Mono<Void> makeProfilePhotoResponse(ServerWebExchange exchange, String email) {
return getForUserEmail(email)
.flatMap(userData -> makeProfilePhotoResponse(exchange, userData));
}
@Override
public Mono<Void> makeProfilePhotoResponse(ServerWebExchange exchange) {
return getForCurrentUser()
.flatMap(userData -> makeProfilePhotoResponse(exchange, userData));
}
private Mono<Void> makeProfilePhotoResponse(ServerWebExchange exchange, UserData userData) {
return Mono.justOrEmpty(userData.getProfilePhotoAssetId())
.flatMap(assetId -> assetService.makeImageResponse(exchange, assetId));
}
}

View File

@ -1,16 +1,31 @@
package com.appsmith.server.services;
import com.appsmith.server.domains.Asset;
import com.appsmith.server.domains.User;
import com.appsmith.server.domains.UserData;
import com.appsmith.server.exceptions.AppsmithException;
import com.appsmith.server.repositories.AssetRepository;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.security.test.context.support.WithUserDetails;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.junit4.SpringRunner;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import reactor.util.function.Tuple2;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
@ -25,6 +40,9 @@ public class UserDataServiceTest {
@Autowired
private UserDataService userDataService;
@Autowired
private AssetRepository assetRepository;
private Mono<User> userMono;
@Before
@ -72,4 +90,81 @@ public class UserDataServiceTest {
.verifyComplete();
}
@Test
@WithUserDetails(value = "api_user")
public void testUploadAndDeleteProfilePhoto_validImage() {
FilePart filepart = Mockito.mock(FilePart.class, Mockito.RETURNS_DEEP_STUBS);
Flux<DataBuffer> dataBufferFlux = DataBufferUtils
.read(new ClassPathResource("test_assets/OrganizationServiceTest/my_organization_logo.png"), new DefaultDataBufferFactory(), 4096)
.cache();
Mockito.when(filepart.content()).thenReturn(dataBufferFlux);
Mockito.when(filepart.headers().getContentType()).thenReturn(MediaType.IMAGE_PNG);
Mono<Tuple2<UserData, Asset>> loadProfileImageMono = userDataService.getForUserEmail("api_user")
.flatMap(userData -> Mono.zip(
Mono.just(userData),
assetRepository.findById(userData.getProfilePhotoAssetId())
));
final Mono<UserData> saveMono = userDataService.saveProfilePhoto(filepart).cache();
final Mono<Tuple2<UserData, Asset>> saveAndGetMono = saveMono.then(loadProfileImageMono);
final Mono<Tuple2<UserData, Asset>> deleteAndGetMono = saveMono.then(userDataService.deleteProfilePhoto()).then(loadProfileImageMono);
StepVerifier.create(saveAndGetMono)
.assertNext(tuple -> {
final UserData userData = tuple.getT1();
assertThat(userData.getProfilePhotoAssetId()).isNotNull();
final Asset asset = tuple.getT2();
assertThat(asset).isNotNull();
DataBuffer buffer = DataBufferUtils.join(dataBufferFlux).block(Duration.ofSeconds(3));
byte[] res = new byte[buffer.readableByteCount()];
buffer.read(res);
assertThat(asset.getData()).isEqualTo(res);
})
.verifyComplete();
StepVerifier.create(deleteAndGetMono)
// Should be empty since the profile photo has been deleted.
.verifyComplete();
}
@Test
@WithUserDetails(value = "api_user")
public void testUploadProfilePhoto_invalidImageFormat() {
FilePart filepart = Mockito.mock(FilePart.class, Mockito.RETURNS_DEEP_STUBS);
Flux<DataBuffer> dataBufferFlux = DataBufferUtils
.read(new ClassPathResource("test_assets/OrganizationServiceTest/my_organization_logo.png"), new DefaultDataBufferFactory(), 4096)
.cache();
Mockito.when(filepart.content()).thenReturn(dataBufferFlux);
Mockito.when(filepart.headers().getContentType()).thenReturn(MediaType.IMAGE_GIF);
final Mono<UserData> saveMono = userDataService.saveProfilePhoto(filepart).cache();
StepVerifier.create(saveMono)
.expectErrorMatches(error -> error instanceof AppsmithException)
.verify();
}
@Test
@WithUserDetails(value = "api_user")
public void testUploadProfilePhoto_invalidImageSize() {
FilePart filepart = Mockito.mock(FilePart.class, Mockito.RETURNS_DEEP_STUBS);
Flux<DataBuffer> dataBufferFlux = DataBufferUtils
.read(new ClassPathResource("test_assets/OrganizationServiceTest/my_organization_logo.png"), new DefaultDataBufferFactory(), 4096)
.repeat(70) // So the file size looks like it's much larger than what it actually is.
.cache();
Mockito.when(filepart.content()).thenReturn(dataBufferFlux);
Mockito.when(filepart.headers().getContentType()).thenReturn(MediaType.IMAGE_PNG);
final Mono<UserData> saveMono = userDataService.saveProfilePhoto(filepart).cache();
StepVerifier.create(saveMono)
.expectErrorMatches(error -> error instanceof AppsmithException)
.verify();
}
}

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 202 227" version="1.1">
<g transform="translate(-319.89 374.57)">
<path
style="stroke:#2fc500;stroke-width:8.8186;fill:#c8ffbf"
d="m422.42-327.99c18.96 0.34 40.63-6.23 41.6-38.09-8.76-0.87-34.18 3.84-35.98 27.68-1.29 17.48-7.93 8.8-10.65-4.07-4.14 1.28-1.12 0.22-6.6 2.32 13.56 21.96 7.09 33.36 9.7 33.16 2.91-0.23 2.01-10.38 1.93-21z"
/>
<path
style="stroke-linejoin:round;stroke:#ec1a00;stroke-width:15;fill:#ffbcb3"
d="m351.65-289.46c-32.21 29.23-17.72 102.7 40.19 121.69 26.38 8.51 59.38 6.46 83.59-11.81 19.87-14.72 36.38-36.23 33.41-79.81-1.69-24.5-38.31-68.49-86.37-41.53-13.24-6.75-38.55-18.28-70.82 11.46z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 791 B