Profile gRPC: Define `getVersionedProfile` endpoint

This commit is contained in:
Katherine Yen 2023-08-30 14:47:11 -07:00 committed by GitHub
parent 5afc058f90
commit dd18fcaea2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 354 additions and 4 deletions

View File

@ -649,7 +649,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.addService(new KeysAnonymousGrpcService(accountsManager, keys)) .addService(new KeysAnonymousGrpcService(accountsManager, keys))
.addService(ServerInterceptors.intercept(new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, .addService(ServerInterceptors.intercept(new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager,
config.getBadges(), asyncCdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters, config.getCdnConfiguration().bucket()), basicCredentialAuthenticationInterceptor)) config.getBadges(), asyncCdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters, config.getCdnConfiguration().bucket()), basicCredentialAuthenticationInterceptor))
.addService(new ProfileAnonymousGrpcService(accountsManager, profileBadgeConverter)); .addService(new ProfileAnonymousGrpcService(accountsManager, profilesManager, profileBadgeConverter));
RemoteDeprecationFilter remoteDeprecationFilter = new RemoteDeprecationFilter(dynamicConfigurationManager); RemoteDeprecationFilter remoteDeprecationFilter = new RemoteDeprecationFilter(dynamicConfigurationManager);
environment.servlets() environment.servlets()

View File

@ -3,6 +3,8 @@ package org.whispersystems.textsecuregcm.grpc;
import io.grpc.Status; import io.grpc.Status;
import org.signal.chat.profile.GetUnversionedProfileAnonymousRequest; import org.signal.chat.profile.GetUnversionedProfileAnonymousRequest;
import org.signal.chat.profile.GetUnversionedProfileResponse; import org.signal.chat.profile.GetUnversionedProfileResponse;
import org.signal.chat.profile.GetVersionedProfileAnonymousRequest;
import org.signal.chat.profile.GetVersionedProfileResponse;
import org.signal.chat.profile.ReactorProfileAnonymousGrpc; import org.signal.chat.profile.ReactorProfileAnonymousGrpc;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
@ -10,16 +12,20 @@ import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
public class ProfileAnonymousGrpcService extends ReactorProfileAnonymousGrpc.ProfileAnonymousImplBase { public class ProfileAnonymousGrpcService extends ReactorProfileAnonymousGrpc.ProfileAnonymousImplBase {
private final AccountsManager accountsManager; private final AccountsManager accountsManager;
private final ProfilesManager profilesManager;
private final ProfileBadgeConverter profileBadgeConverter; private final ProfileBadgeConverter profileBadgeConverter;
public ProfileAnonymousGrpcService( public ProfileAnonymousGrpcService(
final AccountsManager accountsManager, final AccountsManager accountsManager,
final ProfilesManager profilesManager,
final ProfileBadgeConverter profileBadgeConverter) { final ProfileBadgeConverter profileBadgeConverter) {
this.accountsManager = accountsManager; this.accountsManager = accountsManager;
this.profilesManager = profilesManager;
this.profileBadgeConverter = profileBadgeConverter; this.profileBadgeConverter = profileBadgeConverter;
} }
@ -40,8 +46,20 @@ public class ProfileAnonymousGrpcService extends ReactorProfileAnonymousGrpc.Pro
profileBadgeConverter)); profileBadgeConverter));
} }
private Mono<Account> getTargetAccountAndValidateUnidentifiedAccess(final ServiceIdentifier targetIdentifier, final byte[] unidentifiedAccessKey) { @Override
return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(targetIdentifier)) public Mono<GetVersionedProfileResponse> getVersionedProfile(final GetVersionedProfileAnonymousRequest request) {
final ServiceIdentifier targetIdentifier = ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getAccountIdentifier());
if (targetIdentifier.identityType() != IdentityType.ACI) {
throw Status.INVALID_ARGUMENT.withDescription("Expected ACI service identifier").asRuntimeException();
}
return getTargetAccountAndValidateUnidentifiedAccess(targetIdentifier, request.getUnidentifiedAccessKey().toByteArray())
.flatMap(targetAccount -> ProfileGrpcHelper.getVersionedProfile(targetAccount, profilesManager, request.getRequest().getVersion()));
}
private Mono<Account> getTargetAccountAndValidateUnidentifiedAccess(final ServiceIdentifier targetIdentifier, final byte[] unidentifiedAccessKey) {
return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(targetIdentifier))
.flatMap(Mono::justOrEmpty) .flatMap(Mono::justOrEmpty)
.filter(targetAccount -> UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, unidentifiedAccessKey)) .filter(targetAccount -> UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, unidentifiedAccessKey))
.switchIfEmpty(Mono.error(Status.UNAUTHENTICATED.asException())); .switchIfEmpty(Mono.error(Status.UNAUTHENTICATED.asException()));

View File

@ -5,18 +5,51 @@ import com.google.protobuf.ByteString;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import io.grpc.Status;
import org.signal.chat.profile.Badge; import org.signal.chat.profile.Badge;
import org.signal.chat.profile.BadgeSvg; import org.signal.chat.profile.BadgeSvg;
import org.signal.chat.profile.GetUnversionedProfileResponse; import org.signal.chat.profile.GetUnversionedProfileResponse;
import org.signal.chat.profile.GetVersionedProfileResponse;
import org.signal.chat.profile.UserCapabilities; import org.signal.chat.profile.UserCapabilities;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.util.ProfileHelper; import org.whispersystems.textsecuregcm.util.ProfileHelper;
import reactor.core.publisher.Mono;
public class ProfileGrpcHelper { public class ProfileGrpcHelper {
static Mono<GetVersionedProfileResponse> getVersionedProfile(final Account account,
final ProfilesManager profilesManager,
final String requestVersion) {
return Mono.fromFuture(() -> profilesManager.getAsync(account.getUuid(), requestVersion))
.map(maybeProfile -> {
if (maybeProfile.isEmpty()) {
throw Status.NOT_FOUND.withDescription("Profile version not found").asRuntimeException();
}
final GetVersionedProfileResponse.Builder responseBuilder = GetVersionedProfileResponse.newBuilder();
maybeProfile.map(VersionedProfile::name).map(ByteString::copyFrom).ifPresent(responseBuilder::setName);
maybeProfile.map(VersionedProfile::about).map(ByteString::copyFrom).ifPresent(responseBuilder::setAbout);
maybeProfile.map(VersionedProfile::aboutEmoji).map(ByteString::copyFrom).ifPresent(responseBuilder::setAboutEmoji);
maybeProfile.map(VersionedProfile::avatar).ifPresent(responseBuilder::setAvatar);
// Allow requests where either the version matches the latest version on Account or the latest version on Account
// is empty to read the payment address.
maybeProfile
.filter(p -> account.getCurrentProfileVersion().map(v -> v.equals(requestVersion)).orElse(true))
.map(VersionedProfile::paymentAddress)
.map(ByteString::copyFrom)
.ifPresent(responseBuilder::setPaymentAddress);
return responseBuilder.build();
});
}
@VisibleForTesting @VisibleForTesting
static List<Badge> buildBadges(final List<org.whispersystems.textsecuregcm.entities.Badge> badges) { static List<Badge> buildBadges(final List<org.whispersystems.textsecuregcm.entities.Badge> badges) {
final ArrayList<Badge> grpcBadges = new ArrayList<>(); final ArrayList<Badge> grpcBadges = new ArrayList<>();

View File

@ -4,6 +4,8 @@ import com.google.protobuf.ByteString;
import io.grpc.Status; import io.grpc.Status;
import org.signal.chat.profile.GetUnversionedProfileRequest; import org.signal.chat.profile.GetUnversionedProfileRequest;
import org.signal.chat.profile.GetUnversionedProfileResponse; import org.signal.chat.profile.GetUnversionedProfileResponse;
import org.signal.chat.profile.GetVersionedProfileRequest;
import org.signal.chat.profile.GetVersionedProfileResponse;
import org.signal.chat.profile.SetProfileRequest.AvatarChange; import org.signal.chat.profile.SetProfileRequest.AvatarChange;
import org.signal.chat.profile.ProfileAvatarUploadAttributes; import org.signal.chat.profile.ProfileAvatarUploadAttributes;
import org.signal.chat.profile.ReactorProfileGrpc; import org.signal.chat.profile.ReactorProfileGrpc;
@ -15,6 +17,7 @@ import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration; import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.PolicySigner; import org.whispersystems.textsecuregcm.s3.PolicySigner;
@ -169,6 +172,20 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
profileBadgeConverter)); profileBadgeConverter));
} }
@Override
public Mono<GetVersionedProfileResponse> getVersionedProfile(final GetVersionedProfileRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final ServiceIdentifier targetIdentifier =
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getAccountIdentifier());
if (targetIdentifier.identityType() != IdentityType.ACI) {
throw Status.INVALID_ARGUMENT.withDescription("Expected ACI service identifier").asRuntimeException();
}
return validateRateLimitAndGetAccount(authenticatedDevice.accountIdentifier(), targetIdentifier)
.flatMap(account -> ProfileGrpcHelper.getVersionedProfile(account, profilesManager, request.getVersion()));
}
private Mono<Account> validateRateLimitAndGetAccount(final UUID requesterUuid, private Mono<Account> validateRateLimitAndGetAccount(final UUID requesterUuid,
final ServiceIdentifier targetIdentifier) { final ServiceIdentifier targetIdentifier) {
return rateLimiters.getProfileLimiter().validateReactive(requesterUuid) return rateLimiters.getProfileLimiter().validateReactive(requesterUuid)

View File

@ -162,7 +162,7 @@ message GetVersionedProfileRequest {
/** /**
* The profile version to retrieve. * The profile version to retrieve.
*/ */
bytes version = 2; string version = 2;
} }
message GetVersionedProfileAnonymousRequest { message GetVersionedProfileAnonymousRequest {

View File

@ -31,6 +31,9 @@ import org.signal.chat.common.ServiceIdentifier;
import org.signal.chat.profile.GetUnversionedProfileAnonymousRequest; import org.signal.chat.profile.GetUnversionedProfileAnonymousRequest;
import org.signal.chat.profile.GetUnversionedProfileRequest; import org.signal.chat.profile.GetUnversionedProfileRequest;
import org.signal.chat.profile.GetUnversionedProfileResponse; import org.signal.chat.profile.GetUnversionedProfileResponse;
import org.signal.chat.profile.GetVersionedProfileAnonymousRequest;
import org.signal.chat.profile.GetVersionedProfileRequest;
import org.signal.chat.profile.GetVersionedProfileResponse;
import org.signal.chat.profile.ProfileAnonymousGrpc; import org.signal.chat.profile.ProfileAnonymousGrpc;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.ecc.Curve; import org.signal.libsignal.protocol.ecc.Curve;
@ -43,11 +46,16 @@ import org.whispersystems.textsecuregcm.entities.UserCapabilities;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.tests.util.ProfileHelper;
import org.whispersystems.textsecuregcm.util.UUIDUtil; import org.whispersystems.textsecuregcm.util.UUIDUtil;
import javax.annotation.Nullable;
public class ProfileAnonymousGrpcServiceTest { public class ProfileAnonymousGrpcServiceTest {
private Account account; private Account account;
private AccountsManager accountsManager; private AccountsManager accountsManager;
private ProfilesManager profilesManager;
private ProfileBadgeConverter profileBadgeConverter; private ProfileBadgeConverter profileBadgeConverter;
private ProfileAnonymousGrpc.ProfileAnonymousBlockingStub profileAnonymousBlockingStub; private ProfileAnonymousGrpc.ProfileAnonymousBlockingStub profileAnonymousBlockingStub;
@ -58,16 +66,19 @@ public class ProfileAnonymousGrpcServiceTest {
void setup() { void setup() {
account = mock(Account.class); account = mock(Account.class);
accountsManager = mock(AccountsManager.class); accountsManager = mock(AccountsManager.class);
profilesManager = mock(ProfilesManager.class);
profileBadgeConverter = mock(ProfileBadgeConverter.class); profileBadgeConverter = mock(ProfileBadgeConverter.class);
final Metadata metadata = new Metadata(); final Metadata metadata = new Metadata();
metadata.put(AcceptLanguageInterceptor.ACCEPTABLE_LANGUAGES_GRPC_HEADER, "en-us"); metadata.put(AcceptLanguageInterceptor.ACCEPTABLE_LANGUAGES_GRPC_HEADER, "en-us");
metadata.put(UserAgentInterceptor.USER_AGENT_GRPC_HEADER, "Signal-Android/1.2.3");
profileAnonymousBlockingStub = ProfileAnonymousGrpc.newBlockingStub(GRPC_SERVER_EXTENSION.getChannel()) profileAnonymousBlockingStub = ProfileAnonymousGrpc.newBlockingStub(GRPC_SERVER_EXTENSION.getChannel())
.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata)); .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata));
final ProfileAnonymousGrpcService profileAnonymousGrpcService = new ProfileAnonymousGrpcService( final ProfileAnonymousGrpcService profileAnonymousGrpcService = new ProfileAnonymousGrpcService(
accountsManager, accountsManager,
profilesManager,
profileBadgeConverter profileBadgeConverter
); );
@ -166,4 +177,153 @@ public class ProfileAnonymousGrpcServiceTest {
Arguments.of(IdentityType.IDENTITY_TYPE_ACI, false, true) Arguments.of(IdentityType.IDENTITY_TYPE_ACI, false, true)
); );
} }
@ParameterizedTest
@MethodSource
void getVersionedProfile(final String requestVersion,
@Nullable final String accountVersion,
final boolean expectResponseHasPaymentAddress) {
final byte[] unidentifiedAccessKey = new byte[16];
new SecureRandom().nextBytes(unidentifiedAccessKey);
final VersionedProfile profile = mock(VersionedProfile.class);
final byte[] name = ProfileHelper.generateRandomByteArray(81);
final byte[] emoji = ProfileHelper.generateRandomByteArray(60);
final byte[] about = ProfileHelper.generateRandomByteArray(156);
final byte[] paymentAddress = ProfileHelper.generateRandomByteArray(582);
final String avatar = "profiles/" + ProfileHelper.generateRandomBase64FromByteArray(16);
when(profile.name()).thenReturn(name);
when(profile.aboutEmoji()).thenReturn(emoji);
when(profile.about()).thenReturn(about);
when(profile.paymentAddress()).thenReturn(paymentAddress);
when(profile.avatar()).thenReturn(avatar);
when(account.getCurrentProfileVersion()).thenReturn(Optional.ofNullable(accountVersion));
when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false);
when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));
when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
when(profilesManager.getAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(Optional.of(profile)));
final GetVersionedProfileAnonymousRequest request = GetVersionedProfileAnonymousRequest.newBuilder()
.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))
.setRequest(GetVersionedProfileRequest.newBuilder()
.setAccountIdentifier(ServiceIdentifier.newBuilder()
.setIdentityType(IdentityType.IDENTITY_TYPE_ACI)
.setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))
.build())
.setVersion(requestVersion)
.build())
.build();
final GetVersionedProfileResponse response = profileAnonymousBlockingStub.getVersionedProfile(request);
final GetVersionedProfileResponse.Builder expectedResponseBuilder = GetVersionedProfileResponse.newBuilder()
.setName(ByteString.copyFrom(name))
.setAbout(ByteString.copyFrom(about))
.setAboutEmoji(ByteString.copyFrom(emoji))
.setAvatar(avatar);
if (expectResponseHasPaymentAddress) {
expectedResponseBuilder.setPaymentAddress(ByteString.copyFrom(paymentAddress));
}
assertEquals(expectedResponseBuilder.build(), response);
}
private static Stream<Arguments> getVersionedProfile() {
return Stream.of(
Arguments.of("version1", "version1", true),
Arguments.of("version1", null, true),
Arguments.of("version1", "version2", false)
);
}
@Test
void getVersionedProfileVersionNotFound() {
final byte[] unidentifiedAccessKey = new byte[16];
new SecureRandom().nextBytes(unidentifiedAccessKey);
when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));
when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false);
when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
when(profilesManager.getAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(Optional.empty()));
final GetVersionedProfileAnonymousRequest request = GetVersionedProfileAnonymousRequest.newBuilder()
.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))
.setRequest(GetVersionedProfileRequest.newBuilder()
.setAccountIdentifier(ServiceIdentifier.newBuilder()
.setIdentityType(IdentityType.IDENTITY_TYPE_ACI)
.setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))
.build())
.setVersion("someVersion")
.build())
.build();
final StatusRuntimeException statusRuntimeException = assertThrows(StatusRuntimeException.class,
() -> profileAnonymousBlockingStub.getVersionedProfile(request));
assertEquals(Status.NOT_FOUND.getCode(), statusRuntimeException.getStatus().getCode());
}
@ParameterizedTest
@MethodSource
void getVersionedProfileUnauthenticated(final boolean missingUnidentifiedAccessKey,
final boolean accountNotFound) {
final byte[] unidentifiedAccessKey = new byte[16];
new SecureRandom().nextBytes(unidentifiedAccessKey);
when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false);
when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));
when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(
CompletableFuture.completedFuture(accountNotFound ? Optional.empty() : Optional.of(account)));
final GetVersionedProfileAnonymousRequest.Builder requestBuilder = GetVersionedProfileAnonymousRequest.newBuilder()
.setRequest(GetVersionedProfileRequest.newBuilder()
.setAccountIdentifier(ServiceIdentifier.newBuilder()
.setIdentityType(IdentityType.IDENTITY_TYPE_ACI)
.setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))
.build())
.setVersion("someVersion")
.build());
if (!missingUnidentifiedAccessKey) {
requestBuilder.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey));
}
final StatusRuntimeException statusRuntimeException = assertThrows(StatusRuntimeException.class,
() -> profileAnonymousBlockingStub.getVersionedProfile(requestBuilder.build()));
assertEquals(Status.UNAUTHENTICATED.getCode(), statusRuntimeException.getStatus().getCode());
}
private static Stream<Arguments> getVersionedProfileUnauthenticated() {
return Stream.of(
Arguments.of(true, false),
Arguments.of(false, true)
);
}
@Test
void getVersionedProfilePniInvalidArgument() {
final byte[] unidentifiedAccessKey = new byte[16];
new SecureRandom().nextBytes(unidentifiedAccessKey);
final GetVersionedProfileAnonymousRequest request = GetVersionedProfileAnonymousRequest.newBuilder()
.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))
.setRequest(GetVersionedProfileRequest.newBuilder()
.setAccountIdentifier(ServiceIdentifier.newBuilder()
.setIdentityType(IdentityType.IDENTITY_TYPE_PNI)
.setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))
.build())
.setVersion("someVersion")
.build())
.build();
final StatusRuntimeException statusRuntimeException = assertThrows(StatusRuntimeException.class,
() -> profileAnonymousBlockingStub.getVersionedProfile(request));
assertEquals(Status.INVALID_ARGUMENT.getCode(), statusRuntimeException.getStatus().getCode());
}
} }

View File

@ -21,6 +21,8 @@ import org.signal.chat.common.IdentityType;
import org.signal.chat.common.ServiceIdentifier; import org.signal.chat.common.ServiceIdentifier;
import org.signal.chat.profile.GetUnversionedProfileRequest; import org.signal.chat.profile.GetUnversionedProfileRequest;
import org.signal.chat.profile.GetUnversionedProfileResponse; import org.signal.chat.profile.GetUnversionedProfileResponse;
import org.signal.chat.profile.GetVersionedProfileRequest;
import org.signal.chat.profile.GetVersionedProfileResponse;
import org.signal.chat.profile.SetProfileRequest.AvatarChange; import org.signal.chat.profile.SetProfileRequest.AvatarChange;
import org.signal.chat.profile.ProfileGrpc; import org.signal.chat.profile.ProfileGrpc;
import org.signal.chat.profile.SetProfileRequest; import org.signal.chat.profile.SetProfileRequest;
@ -55,10 +57,12 @@ import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile; import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.tests.util.ProfileHelper;
import org.whispersystems.textsecuregcm.util.UUIDUtil; import org.whispersystems.textsecuregcm.util.UUIDUtil;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import software.amazon.awssdk.services.s3.S3AsyncClient; import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import javax.annotation.Nullable;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
@ -140,6 +144,7 @@ public class ProfileGrpcServiceTest {
PhoneNumberUtil.PhoneNumberFormat.E164); PhoneNumberUtil.PhoneNumberFormat.E164);
final Metadata metadata = new Metadata(); final Metadata metadata = new Metadata();
metadata.put(AcceptLanguageInterceptor.ACCEPTABLE_LANGUAGES_GRPC_HEADER, "en-us"); metadata.put(AcceptLanguageInterceptor.ACCEPTABLE_LANGUAGES_GRPC_HEADER, "en-us");
metadata.put(UserAgentInterceptor.USER_AGENT_GRPC_HEADER, "Signal-Android/1.2.3");
profileBlockingStub = ProfileGrpc.newBlockingStub(GRPC_SERVER_EXTENSION.getChannel()) profileBlockingStub = ProfileGrpc.newBlockingStub(GRPC_SERVER_EXTENSION.getChannel())
.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata)); .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata));
@ -472,4 +477,121 @@ public class ProfileGrpcServiceTest {
verifyNoInteractions(accountsManager); verifyNoInteractions(accountsManager);
} }
@ParameterizedTest
@MethodSource
void getVersionedProfile(final String requestVersion, @Nullable final String accountVersion, final boolean expectResponseHasPaymentAddress) {
final VersionedProfile profile = mock(VersionedProfile.class);
final byte[] name = ProfileHelper.generateRandomByteArray(81);
final byte[] emoji = ProfileHelper.generateRandomByteArray(60);
final byte[] about = ProfileHelper.generateRandomByteArray(156);
final byte[] paymentAddress = ProfileHelper.generateRandomByteArray(582);
final String avatar = "profiles/" + ProfileHelper.generateRandomBase64FromByteArray(16);
final GetVersionedProfileRequest request = GetVersionedProfileRequest.newBuilder()
.setAccountIdentifier(ServiceIdentifier.newBuilder()
.setIdentityType(IdentityType.IDENTITY_TYPE_ACI)
.setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))
.build())
.setVersion(requestVersion)
.build();
when(profile.name()).thenReturn(name);
when(profile.about()).thenReturn(about);
when(profile.aboutEmoji()).thenReturn(emoji);
when(profile.avatar()).thenReturn(avatar);
when(profile.paymentAddress()).thenReturn(paymentAddress);
when(account.getCurrentProfileVersion()).thenReturn(Optional.ofNullable(accountVersion));
when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
when(profilesManager.getAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(Optional.of(profile)));
final GetVersionedProfileResponse response = profileBlockingStub.getVersionedProfile(request);
final GetVersionedProfileResponse.Builder expectedResponseBuilder = GetVersionedProfileResponse.newBuilder()
.setName(ByteString.copyFrom(name))
.setAbout(ByteString.copyFrom(about))
.setAboutEmoji(ByteString.copyFrom(emoji))
.setAvatar(avatar);
if (expectResponseHasPaymentAddress) {
expectedResponseBuilder.setPaymentAddress(ByteString.copyFrom(paymentAddress));
}
assertEquals(expectedResponseBuilder.build(), response);
}
private static Stream<Arguments> getVersionedProfile() {
return Stream.of(
Arguments.of("version1", "version1", true),
Arguments.of("version1", null, true),
Arguments.of("version1", "version2", false)
);
}
@ParameterizedTest
@MethodSource
void getVersionedProfileAccountOrProfileNotFound(final boolean missingAccount, final boolean missingProfile) {
final GetVersionedProfileRequest request = GetVersionedProfileRequest.newBuilder()
.setAccountIdentifier(ServiceIdentifier.newBuilder()
.setIdentityType(IdentityType.IDENTITY_TYPE_ACI)
.setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))
.build())
.setVersion("versionWithNoProfile")
.build();
when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(missingAccount ? Optional.empty() : Optional.of(account)));
when(profilesManager.getAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(missingProfile ? Optional.empty() : Optional.of(profile)));
final StatusRuntimeException statusRuntimeException = assertThrows(StatusRuntimeException.class,
() -> profileBlockingStub.getVersionedProfile(request));
assertEquals(Status.NOT_FOUND.getCode(), statusRuntimeException.getStatus().getCode());
}
private static Stream<Arguments> getVersionedProfileAccountOrProfileNotFound() {
return Stream.of(
Arguments.of(true, false),
Arguments.of(false, true)
);
}
@Test
void getVersionedProfileRatelimited() {
final Duration retryAfterDuration = Duration.ofMinutes(7);
when(rateLimiter.validateReactive(any(UUID.class)))
.thenReturn(Mono.error(new RateLimitExceededException(retryAfterDuration, false)));
final GetVersionedProfileRequest request = GetVersionedProfileRequest.newBuilder()
.setAccountIdentifier(ServiceIdentifier.newBuilder()
.setIdentityType(IdentityType.IDENTITY_TYPE_ACI)
.setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))
.build())
.setVersion("someVersion")
.build();
final StatusRuntimeException exception = assertThrows(StatusRuntimeException.class,
() -> profileBlockingStub.getVersionedProfile(request));
assertEquals(Status.Code.RESOURCE_EXHAUSTED, exception.getStatus().getCode());
assertNotNull(exception.getTrailers());
assertEquals(retryAfterDuration, exception.getTrailers().get(RateLimitUtil.RETRY_AFTER_DURATION_KEY));
verifyNoInteractions(accountsManager);
verifyNoInteractions(profilesManager);
}
@Test
void getVersionedProfilePniInvalidArgument() {
final GetVersionedProfileRequest request = GetVersionedProfileRequest.newBuilder()
.setAccountIdentifier(ServiceIdentifier.newBuilder()
.setIdentityType(IdentityType.IDENTITY_TYPE_PNI)
.setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))
.build())
.setVersion("someVersion")
.build();
final StatusRuntimeException exception = assertThrows(StatusRuntimeException.class,
() -> profileBlockingStub.getVersionedProfile(request));
assertEquals(Status.INVALID_ARGUMENT.getCode(), exception.getStatus().getCode());
}
} }