diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 54e49c91d..0e6cc0556 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -649,7 +649,7 @@ public class WhisperServerService extends Application getTargetAccountAndValidateUnidentifiedAccess(final ServiceIdentifier targetIdentifier, final byte[] unidentifiedAccessKey) { - return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(targetIdentifier)) + @Override + public Mono 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 getTargetAccountAndValidateUnidentifiedAccess(final ServiceIdentifier targetIdentifier, final byte[] unidentifiedAccessKey) { + return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(targetIdentifier)) .flatMap(Mono::justOrEmpty) .filter(targetAccount -> UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, unidentifiedAccessKey)) .switchIfEmpty(Mono.error(Status.UNAUTHENTICATED.asException())); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java index f029b4ba0..65aac798a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java @@ -5,18 +5,51 @@ import com.google.protobuf.ByteString; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import io.grpc.Status; import org.signal.chat.profile.Badge; import org.signal.chat.profile.BadgeSvg; import org.signal.chat.profile.GetUnversionedProfileResponse; +import org.signal.chat.profile.GetVersionedProfileResponse; import org.signal.chat.profile.UserCapabilities; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum; import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; 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 reactor.core.publisher.Mono; public class ProfileGrpcHelper { + static Mono 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 static List buildBadges(final List badges) { final ArrayList grpcBadges = new ArrayList<>(); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java index 23bb02a58..3a175bc10 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java @@ -4,6 +4,8 @@ import com.google.protobuf.ByteString; import io.grpc.Status; import org.signal.chat.profile.GetUnversionedProfileRequest; 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.ProfileAvatarUploadAttributes; 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.BadgesConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; +import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.s3.PolicySigner; @@ -169,6 +172,20 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase { profileBadgeConverter)); } + @Override + public Mono 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 validateRateLimitAndGetAccount(final UUID requesterUuid, final ServiceIdentifier targetIdentifier) { return rateLimiters.getProfileLimiter().validateReactive(requesterUuid) diff --git a/service/src/main/proto/org/signal/chat/profile.proto b/service/src/main/proto/org/signal/chat/profile.proto index 8ce6609b0..fb175aead 100644 --- a/service/src/main/proto/org/signal/chat/profile.proto +++ b/service/src/main/proto/org/signal/chat/profile.proto @@ -162,7 +162,7 @@ message GetVersionedProfileRequest { /** * The profile version to retrieve. */ - bytes version = 2; + string version = 2; } message GetVersionedProfileAnonymousRequest { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java index 1e27ebf7b..82ad9e0b8 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java @@ -31,6 +31,9 @@ import org.signal.chat.common.ServiceIdentifier; import org.signal.chat.profile.GetUnversionedProfileAnonymousRequest; import org.signal.chat.profile.GetUnversionedProfileRequest; 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.libsignal.protocol.IdentityKey; 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.storage.Account; 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 javax.annotation.Nullable; public class ProfileAnonymousGrpcServiceTest { private Account account; private AccountsManager accountsManager; + private ProfilesManager profilesManager; private ProfileBadgeConverter profileBadgeConverter; private ProfileAnonymousGrpc.ProfileAnonymousBlockingStub profileAnonymousBlockingStub; @@ -58,16 +66,19 @@ public class ProfileAnonymousGrpcServiceTest { void setup() { account = mock(Account.class); accountsManager = mock(AccountsManager.class); + profilesManager = mock(ProfilesManager.class); profileBadgeConverter = mock(ProfileBadgeConverter.class); final Metadata metadata = new Metadata(); 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()) .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata)); final ProfileAnonymousGrpcService profileAnonymousGrpcService = new ProfileAnonymousGrpcService( accountsManager, + profilesManager, profileBadgeConverter ); @@ -166,4 +177,153 @@ public class ProfileAnonymousGrpcServiceTest { 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 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 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()); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java index 34fbbf9bb..d49a6cbac 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java @@ -21,6 +21,8 @@ import org.signal.chat.common.IdentityType; import org.signal.chat.common.ServiceIdentifier; import org.signal.chat.profile.GetUnversionedProfileRequest; 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.ProfileGrpc; 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.VersionedProfile; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import org.whispersystems.textsecuregcm.tests.util.ProfileHelper; import org.whispersystems.textsecuregcm.util.UUIDUtil; import reactor.core.publisher.Mono; import software.amazon.awssdk.services.s3.S3AsyncClient; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import javax.annotation.Nullable; import java.security.SecureRandom; import java.time.Clock; import java.time.Duration; @@ -140,6 +144,7 @@ public class ProfileGrpcServiceTest { PhoneNumberUtil.PhoneNumberFormat.E164); final Metadata metadata = new Metadata(); 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()) .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata)); @@ -472,4 +477,121 @@ public class ProfileGrpcServiceTest { 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 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 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()); + } }