diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java new file mode 100644 index 000000000..aa3d0250c --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java @@ -0,0 +1,212 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +import com.google.protobuf.ByteString; +import io.grpc.Status; +import java.util.Base64; +import java.util.Objects; +import javax.annotation.Nullable; +import org.apache.commons.lang3.StringUtils; +import org.signal.chat.device.ClearPushTokenRequest; +import org.signal.chat.device.ClearPushTokenResponse; +import org.signal.chat.device.GetDevicesRequest; +import org.signal.chat.device.GetDevicesResponse; +import org.signal.chat.device.ReactorDevicesGrpc; +import org.signal.chat.device.RemoveDeviceRequest; +import org.signal.chat.device.RemoveDeviceResponse; +import org.signal.chat.device.SetCapabilitiesRequest; +import org.signal.chat.device.SetCapabilitiesResponse; +import org.signal.chat.device.SetDeviceNameRequest; +import org.signal.chat.device.SetDeviceNameResponse; +import org.signal.chat.device.SetPushTokenRequest; +import org.signal.chat.device.SetPushTokenResponse; +import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice; +import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.KeysManager; +import org.whispersystems.textsecuregcm.storage.MessagesManager; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class DevicesGrpcService extends ReactorDevicesGrpc.DevicesImplBase { + + private final AccountsManager accountsManager; + private final KeysManager keysManager; + private final MessagesManager messagesManager; + + private static final int MAX_NAME_LENGTH = 256; + + public DevicesGrpcService(final AccountsManager accountsManager, + final KeysManager keysManager, + final MessagesManager messagesManager) { + + this.accountsManager = accountsManager; + this.keysManager = keysManager; + this.messagesManager = messagesManager; + } + + @Override + public Mono getDevices(final GetDevicesRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMapMany(account -> Flux.fromIterable(account.getDevices())) + .reduce(GetDevicesResponse.newBuilder(), (builder, device) -> { + final GetDevicesResponse.LinkedDevice.Builder linkedDeviceBuilder = GetDevicesResponse.LinkedDevice.newBuilder(); + + if (StringUtils.isNotBlank(device.getName())) { + linkedDeviceBuilder.setName(ByteString.copyFrom(Base64.getDecoder().decode(device.getName()))); + } + + return builder.addDevices(linkedDeviceBuilder + .setId(device.getId()) + .setCreated(device.getCreated()) + .setLastSeen(device.getLastSeen()) + .build()); + }) + .map(GetDevicesResponse.Builder::build); + } + + @Override + public Mono removeDevice(final RemoveDeviceRequest request) { + if (request.getId() == Device.MASTER_ID) { + throw Status.INVALID_ARGUMENT.withDescription("Cannot remove primary device").asRuntimeException(); + } + + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice(); + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> Flux.merge( + Mono.fromFuture(() -> messagesManager.clear(account.getUuid(), request.getId())), + Mono.fromFuture(() -> keysManager.delete(account.getUuid(), request.getId()))) + .then(Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> a.removeDevice(request.getId())))) + // Some messages may have arrived while we were performing the other updates; make a best effort to clear + // those out, too + .then(Mono.fromFuture(() -> messagesManager.clear(account.getUuid(), request.getId())))) + .thenReturn(RemoveDeviceResponse.newBuilder().build()); + } + + @Override + public Mono setDeviceName(final SetDeviceNameRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + if (request.getName().isEmpty()) { + throw Status.INVALID_ARGUMENT.withDescription("Must specify a device name").asRuntimeException(); + } + + if (request.getName().size() > MAX_NAME_LENGTH) { + throw Status.INVALID_ARGUMENT.withDescription("Device name must be at most " + MAX_NAME_LENGTH + " bytes") + .asRuntimeException(); + } + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> Mono.fromFuture(() -> accountsManager.updateDeviceAsync(account, authenticatedDevice.deviceId(), + device -> device.setName(Base64.getEncoder().encodeToString(request.getName().toByteArray()))))) + .thenReturn(SetDeviceNameResponse.newBuilder().build()); + } + + @Override + public Mono setPushToken(final SetPushTokenRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + @Nullable final String apnsToken; + @Nullable final String apnsVoipToken; + @Nullable final String fcmToken; + + switch (request.getTokenRequestCase()) { + + case APNS_TOKEN_REQUEST -> { + final SetPushTokenRequest.ApnsTokenRequest apnsTokenRequest = request.getApnsTokenRequest(); + + if (StringUtils.isAllBlank(apnsTokenRequest.getApnsToken(), apnsTokenRequest.getApnsVoipToken())) { + throw Status.INVALID_ARGUMENT.withDescription("APNs tokens may not both be blank").asRuntimeException(); + } + + apnsToken = StringUtils.stripToNull(apnsTokenRequest.getApnsToken()); + apnsVoipToken = StringUtils.stripToNull(apnsTokenRequest.getApnsVoipToken()); + fcmToken = null; + } + + case FCM_TOKEN_REQUEST -> { + final SetPushTokenRequest.FcmTokenRequest fcmTokenRequest = request.getFcmTokenRequest(); + + if (StringUtils.isBlank(fcmTokenRequest.getFcmToken())) { + throw Status.INVALID_ARGUMENT.withDescription("FCM token must not be blank").asRuntimeException(); + } + + apnsToken = null; + apnsVoipToken = null; + fcmToken = StringUtils.stripToNull(fcmTokenRequest.getFcmToken()); + } + + default -> throw Status.INVALID_ARGUMENT.withDescription("No tokens specified").asRuntimeException(); + } + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> { + final Device device = account.getDevice(authenticatedDevice.deviceId()) + .orElseThrow(Status.UNAUTHENTICATED::asRuntimeException); + + final boolean tokenUnchanged = + Objects.equals(device.getApnId(), apnsToken) && + Objects.equals(device.getVoipApnId(), apnsVoipToken) && + Objects.equals(device.getGcmId(), fcmToken); + + return tokenUnchanged + ? Mono.empty() + : Mono.fromFuture(() -> accountsManager.updateDeviceAsync(account, authenticatedDevice.deviceId(), d -> { + d.setApnId(apnsToken); + d.setVoipApnId(apnsVoipToken); + d.setGcmId(fcmToken); + d.setFetchesMessages(false); + })); + }) + .thenReturn(SetPushTokenResponse.newBuilder().build()); + } + + @Override + public Mono clearPushToken(final ClearPushTokenRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> Mono.fromFuture(() -> accountsManager.updateDeviceAsync(account, authenticatedDevice.deviceId(), device -> { + if (StringUtils.isNotBlank(device.getApnId()) || StringUtils.isNotBlank(device.getVoipApnId())) { + device.setUserAgent(device.isMaster() ? "OWI" : "OWP"); + } else if (StringUtils.isNotBlank(device.getGcmId())) { + device.setUserAgent("OWA"); + } + + device.setApnId(null); + device.setVoipApnId(null); + device.setGcmId(null); + device.setFetchesMessages(true); + }))) + .thenReturn(ClearPushTokenResponse.newBuilder().build()); + } + + @Override + public Mono setCapabilities(final SetCapabilitiesRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> + Mono.fromFuture(() -> accountsManager.updateDeviceAsync(account, authenticatedDevice.deviceId(), + d -> d.setCapabilities(new Device.DeviceCapabilities( + request.getStorage(), + request.getTransfer(), + request.getPni(), + request.getPaymentActivation()))))) + .thenReturn(SetCapabilitiesResponse.newBuilder().build()); + } +} diff --git a/service/src/main/proto/org/signal/chat/device.proto b/service/src/main/proto/org/signal/chat/device.proto new file mode 100644 index 000000000..7842a9e40 --- /dev/null +++ b/service/src/main/proto/org/signal/chat/device.proto @@ -0,0 +1,149 @@ +syntax = "proto3"; + +option java_multiple_files = true; + +package org.signal.chat.device; + +/** + * Provides methods for working with devices attached to a Signal account. + */ +service Devices { + /** + * Returns a list of devices associated with the caller's account. + */ + rpc GetDevices(GetDevicesRequest) returns (GetDevicesResponse) {} + + /** + * Removes a linked device from the caller's account. This call will fail with + * a status of `PERMISSION_DENIED` if not called from the primary device + * associated with an account. It will also fail with a status of + * `INVALID_ARGUMENT` if the targeted device is the primary device associated + * with the account. + */ + rpc RemoveDevice(RemoveDeviceRequest) returns (RemoveDeviceResponse) {} + + rpc SetDeviceName(SetDeviceNameRequest) returns (SetDeviceNameResponse) {} + + /** + * Sets the token(s) the server should use to send new message notifications + * to the authenticated device. + */ + rpc SetPushToken(SetPushTokenRequest) returns (SetPushTokenResponse) {} + + /** + * Removes any push tokens associated with the authenticated device. After + * calling this method, the server will assume that the authenticated device + * will periodically poll for new messages. + */ + rpc ClearPushToken(ClearPushTokenRequest) returns (ClearPushTokenResponse) {} + + /** + * Declares that the authenticated device supports certain features. + */ + rpc SetCapabilities(SetCapabilitiesRequest) returns (SetCapabilitiesResponse) {} +} + +message GetDevicesRequest {} + +message GetDevicesResponse { + message LinkedDevice { + /** + * The identifier for the device within an account. + */ + uint64 id = 1; + + /** + * A sequence of bytes that encodes an encrypted human-readable name for + * this device. + */ + bytes name = 2; + + /** + * The time, in milliseconds since the epoch, at which this device was + * attached to its parent account. + */ + uint64 created = 3; + + /** + * The approximate time, in milliseconds since the epoch, at which this + * device last connected to the server. + */ + uint64 last_seen = 4; + } + + /** + * A list of devices linked to the authenticated account. + */ + repeated LinkedDevice devices = 1; +} + +message RemoveDeviceRequest { + /** + * The identifier for the device to remove from the authenticated account. + */ + uint64 id = 1; +} + +message SetDeviceNameRequest { + /** + * A sequence of bytes that encodes an encrypted human-readable name for this + * device. + */ + bytes name = 1; +} + +message SetDeviceNameResponse {} + +message RemoveDeviceResponse {} + +message SetPushTokenRequest { + message ApnsTokenRequest { + /** + * A "standard" APNs device token. + */ + string apns_token = 1; + + /** + * A VoIP APNs device token. If present, the server will prefer to send + * message notifications to the device using this token on a VOIP APNs + * topic. + */ + string apns_voip_token = 2; + } + + message FcmTokenRequest { + /** + * An FCM push token. + */ + string fcm_token = 1; + } + + oneof token_request { + /** + * If present, specifies the APNs device token(s) the server will use to + * send new message notifications to the authenticated device. + */ + ApnsTokenRequest apns_token_request = 1; + + /** + * If present, specifies the FCM push token the server will use to send new + * message notifications to the authenticated device. + */ + FcmTokenRequest fcm_token_request = 2; + } +} + +message SetPushTokenResponse {} + +message ClearPushTokenRequest {} + +message ClearPushTokenResponse {} + +message SetCapabilitiesRequest { + bool storage = 1; + bool transfer = 2; + bool pni = 3; + bool paymentActivation = 4; +} + +message SetCapabilitiesResponse {} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java new file mode 100644 index 000000000..4effc71f5 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java @@ -0,0 +1,453 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.protobuf.ByteString; +import io.grpc.ServerInterceptors; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.junitpioneer.jupiter.cartesian.CartesianTest; +import org.signal.chat.device.ClearPushTokenRequest; +import org.signal.chat.device.ClearPushTokenResponse; +import org.signal.chat.device.DevicesGrpc; +import org.signal.chat.device.GetDevicesRequest; +import org.signal.chat.device.GetDevicesResponse; +import org.signal.chat.device.RemoveDeviceRequest; +import org.signal.chat.device.RemoveDeviceResponse; +import org.signal.chat.device.SetCapabilitiesRequest; +import org.signal.chat.device.SetCapabilitiesResponse; +import org.signal.chat.device.SetDeviceNameRequest; +import org.signal.chat.device.SetDeviceNameResponse; +import org.signal.chat.device.SetPushTokenRequest; +import org.signal.chat.device.SetPushTokenResponse; +import org.whispersystems.textsecuregcm.auth.grpc.MockAuthenticationInterceptor; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.KeysManager; +import org.whispersystems.textsecuregcm.storage.MessagesManager; + +class DevicesGrpcServiceTest { + + private AccountsManager accountsManager; + private KeysManager keysManager; + private MessagesManager messagesManager; + + private Account authenticatedAccount; + + private MockAuthenticationInterceptor mockAuthenticationInterceptor; + private DevicesGrpc.DevicesBlockingStub devicesStub; + + @RegisterExtension + static final GrpcServerExtension GRPC_SERVER_EXTENSION = new GrpcServerExtension(); + + private static final UUID AUTHENTICATED_ACI = UUID.randomUUID(); + private static final long AUTHENTICATED_DEVICE_ID = Device.MASTER_ID; + + @BeforeEach + void setUp() { + accountsManager = mock(AccountsManager.class); + keysManager = mock(KeysManager.class); + messagesManager = mock(MessagesManager.class); + + authenticatedAccount = mock(Account.class); + when(authenticatedAccount.getUuid()).thenReturn(AUTHENTICATED_ACI); + + mockAuthenticationInterceptor = new MockAuthenticationInterceptor(); + mockAuthenticationInterceptor.setAuthenticatedDevice(AUTHENTICATED_ACI, AUTHENTICATED_DEVICE_ID); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(authenticatedAccount))); + + when(accountsManager.updateAsync(any(), any())) + .thenAnswer(invocation -> { + final Account account = invocation.getArgument(0); + final Consumer updater = invocation.getArgument(1); + + updater.accept(account); + + return CompletableFuture.completedFuture(account); + }); + + when(accountsManager.updateDeviceAsync(any(), anyLong(), any())) + .thenAnswer(invocation -> { + final Account account = invocation.getArgument(0); + final Device device = account.getDevice(invocation.getArgument(1)).orElseThrow(); + final Consumer updater = invocation.getArgument(2); + + updater.accept(device); + + return CompletableFuture.completedFuture(account); + }); + + when(keysManager.delete(any(), anyLong())).thenReturn(CompletableFuture.completedFuture(null)); + when(messagesManager.clear(any(), anyLong())).thenReturn(CompletableFuture.completedFuture(null)); + + final DevicesGrpcService devicesGrpcService = new DevicesGrpcService(accountsManager, keysManager, messagesManager); + devicesStub = DevicesGrpc.newBlockingStub(GRPC_SERVER_EXTENSION.getChannel()); + + GRPC_SERVER_EXTENSION.getServiceRegistry() + .addService(ServerInterceptors.intercept(devicesGrpcService, mockAuthenticationInterceptor)); + } + + @Test + void getDevices() { + final Instant primaryDeviceCreated = Instant.now().minus(Duration.ofDays(7)).truncatedTo(ChronoUnit.MILLIS); + final Instant primaryDeviceLastSeen = primaryDeviceCreated.plus(Duration.ofHours(6)); + final Instant linkedDeviceCreated = Instant.now().minus(Duration.ofDays(1)).truncatedTo(ChronoUnit.MILLIS); + final Instant linkedDeviceLastSeen = linkedDeviceCreated.plus(Duration.ofHours(7)); + + final Device primaryDevice = mock(Device.class); + when(primaryDevice.getId()).thenReturn(Device.MASTER_ID); + when(primaryDevice.getCreated()).thenReturn(primaryDeviceCreated.toEpochMilli()); + when(primaryDevice.getLastSeen()).thenReturn(primaryDeviceLastSeen.toEpochMilli()); + + final String linkedDeviceName = "A linked device"; + + final Device linkedDevice = mock(Device.class); + when(linkedDevice.getId()).thenReturn(Device.MASTER_ID + 1); + when(linkedDevice.getCreated()).thenReturn(linkedDeviceCreated.toEpochMilli()); + when(linkedDevice.getLastSeen()).thenReturn(linkedDeviceLastSeen.toEpochMilli()); + when(linkedDevice.getName()) + .thenReturn(Base64.getEncoder().encodeToString(linkedDeviceName.getBytes(StandardCharsets.UTF_8))); + + when(authenticatedAccount.getDevices()).thenReturn(List.of(primaryDevice, linkedDevice)); + + final GetDevicesResponse expectedResponse = GetDevicesResponse.newBuilder() + .addDevices(GetDevicesResponse.LinkedDevice.newBuilder() + .setId(Device.MASTER_ID) + .setCreated(primaryDeviceCreated.toEpochMilli()) + .setLastSeen(primaryDeviceLastSeen.toEpochMilli()) + .build()) + .addDevices(GetDevicesResponse.LinkedDevice.newBuilder() + .setId(Device.MASTER_ID + 1) + .setCreated(linkedDeviceCreated.toEpochMilli()) + .setLastSeen(linkedDeviceLastSeen.toEpochMilli()) + .setName(ByteString.copyFrom(linkedDeviceName.getBytes(StandardCharsets.UTF_8))) + .build()) + .build(); + + assertEquals(expectedResponse, devicesStub.getDevices(GetDevicesRequest.newBuilder().build())); + } + + @Test + void removeDevice() { + final long deviceId = 17; + + final RemoveDeviceResponse ignored = devicesStub.removeDevice(RemoveDeviceRequest.newBuilder() + .setId(deviceId) + .build()); + + verify(messagesManager, times(2)).clear(AUTHENTICATED_ACI, deviceId); + verify(keysManager).delete(AUTHENTICATED_ACI, deviceId); + verify(authenticatedAccount).removeDevice(deviceId); + } + + @Test + void removeDevicePrimary() { + final StatusRuntimeException exception = assertThrows(StatusRuntimeException.class, + () -> devicesStub.removeDevice(RemoveDeviceRequest.newBuilder() + .setId(1) + .build())); + + assertEquals(Status.Code.INVALID_ARGUMENT, exception.getStatus().getCode()); + } + + @Test + void removeDeviceNonPrimaryAuthenticated() { + mockAuthenticationInterceptor.setAuthenticatedDevice(AUTHENTICATED_ACI, Device.MASTER_ID + 1); + + final StatusRuntimeException exception = assertThrows(StatusRuntimeException.class, + () -> devicesStub.removeDevice(RemoveDeviceRequest.newBuilder() + .setId(17) + .build())); + + assertEquals(Status.Code.PERMISSION_DENIED, exception.getStatus().getCode()); + } + + @ParameterizedTest + @ValueSource(longs = {Device.MASTER_ID, Device.MASTER_ID + 1}) + void setDeviceName(final long deviceId) { + mockAuthenticationInterceptor.setAuthenticatedDevice(AUTHENTICATED_ACI, deviceId); + + final Device device = mock(Device.class); + when(authenticatedAccount.getDevice(deviceId)).thenReturn(Optional.of(device)); + + final byte[] deviceName = new byte[128]; + ThreadLocalRandom.current().nextBytes(deviceName); + + final SetDeviceNameResponse ignored = devicesStub.setDeviceName(SetDeviceNameRequest.newBuilder() + .setName(ByteString.copyFrom(deviceName)) + .build()); + + verify(device).setName(Base64.getEncoder().encodeToString(deviceName)); + } + + @ParameterizedTest + @MethodSource + void setDeviceNameIllegalArgument(final SetDeviceNameRequest request) { + when(authenticatedAccount.getDevice(AUTHENTICATED_DEVICE_ID)).thenReturn(Optional.of(mock(Device.class))); + + final StatusRuntimeException exception = + assertThrows(StatusRuntimeException.class, () -> devicesStub.setDeviceName(request)); + + assertEquals(Status.Code.INVALID_ARGUMENT, exception.getStatus().getCode()); + } + + private static Stream setDeviceNameIllegalArgument() { + return Stream.of( + // No device name + Arguments.of(SetDeviceNameRequest.newBuilder().build()), + + // Excessively-long device name + Arguments.of(SetDeviceNameRequest.newBuilder() + .setName(ByteString.copyFrom(RandomStringUtils.randomAlphanumeric(1024).getBytes(StandardCharsets.UTF_8))) + .build()) + ); + } + + @ParameterizedTest + @MethodSource + void setPushToken(final long deviceId, + final SetPushTokenRequest request, + @Nullable final String expectedApnsToken, + @Nullable final String expectedApnsVoipToken, + @Nullable final String expectedFcmToken) { + + mockAuthenticationInterceptor.setAuthenticatedDevice(AUTHENTICATED_ACI, deviceId); + + final Device device = mock(Device.class); + when(authenticatedAccount.getDevice(deviceId)).thenReturn(Optional.of(device)); + + final SetPushTokenResponse ignored = devicesStub.setPushToken(request); + + verify(device).setApnId(expectedApnsToken); + verify(device).setVoipApnId(expectedApnsVoipToken); + verify(device).setGcmId(expectedFcmToken); + verify(device).setFetchesMessages(false); + } + + private static Stream setPushToken() { + final String apnsToken = "apns-token"; + final String apnsVoipToken = "apns-voip-token"; + final String fcmToken = "fcm-token"; + + final Stream.Builder streamBuilder = Stream.builder(); + + for (final long deviceId : new long[] { Device.MASTER_ID, Device.MASTER_ID + 1 }) { + streamBuilder.add(Arguments.of(deviceId, + SetPushTokenRequest.newBuilder() + .setApnsTokenRequest(SetPushTokenRequest.ApnsTokenRequest.newBuilder() + .setApnsToken(apnsToken) + .setApnsVoipToken(apnsVoipToken) + .build()) + .build(), + apnsToken, apnsVoipToken, null)); + + streamBuilder.add(Arguments.of(deviceId, + SetPushTokenRequest.newBuilder() + .setApnsTokenRequest(SetPushTokenRequest.ApnsTokenRequest.newBuilder() + .setApnsToken(apnsToken) + .build()) + .build(), + apnsToken, null, null)); + + streamBuilder.add(Arguments.of(deviceId, + SetPushTokenRequest.newBuilder() + .setFcmTokenRequest(SetPushTokenRequest.FcmTokenRequest.newBuilder() + .setFcmToken(fcmToken) + .build()) + .build(), + null, null, fcmToken)); + } + + return streamBuilder.build(); + } + + @ParameterizedTest + @MethodSource + void setPushTokenUnchanged(final SetPushTokenRequest request, + @Nullable final String apnsToken, + @Nullable final String apnsVoipToken, + @Nullable final String fcmToken) { + + final Device device = mock(Device.class); + when(device.getApnId()).thenReturn(apnsToken); + when(device.getVoipApnId()).thenReturn(apnsVoipToken); + when(device.getGcmId()).thenReturn(fcmToken); + + when(authenticatedAccount.getDevice(AUTHENTICATED_DEVICE_ID)).thenReturn(Optional.of(device)); + + final SetPushTokenResponse ignored = devicesStub.setPushToken(request); + + verify(accountsManager, never()).updateDevice(any(), anyLong(), any()); + } + + private static Stream setPushTokenUnchanged() { + final String apnsToken = "apns-token"; + final String apnsVoipToken = "apns-voip-token"; + final String fcmToken = "fcm-token"; + + return Stream.of( + Arguments.of(SetPushTokenRequest.newBuilder() + .setApnsTokenRequest(SetPushTokenRequest.ApnsTokenRequest.newBuilder() + .setApnsToken(apnsToken) + .setApnsVoipToken(apnsVoipToken) + .build()) + .build(), + apnsToken, apnsVoipToken, null, false), + + Arguments.of(SetPushTokenRequest.newBuilder() + .setApnsTokenRequest(SetPushTokenRequest.ApnsTokenRequest.newBuilder() + .setApnsToken(apnsToken) + .build()) + .build(), + apnsToken, null, null, false), + + Arguments.of(SetPushTokenRequest.newBuilder() + .setFcmTokenRequest(SetPushTokenRequest.FcmTokenRequest.newBuilder() + .setFcmToken(fcmToken) + .build()) + .build(), + null, null, fcmToken, false) + ); + } + + @ParameterizedTest + @MethodSource + void setPushTokenIllegalArgument(final SetPushTokenRequest request) { + final Device device = mock(Device.class); + when(authenticatedAccount.getDevice(AUTHENTICATED_DEVICE_ID)).thenReturn(Optional.of(device)); + + final StatusRuntimeException exception = + assertThrows(StatusRuntimeException.class, () -> devicesStub.setPushToken(request)); + + assertEquals(Status.Code.INVALID_ARGUMENT, exception.getStatus().getCode()); + + verify(accountsManager, never()).updateDevice(any(), anyLong(), any()); + } + + private static Stream setPushTokenIllegalArgument() { + return Stream.of( + Arguments.of(SetPushTokenRequest.newBuilder().build()), + + Arguments.of(SetPushTokenRequest.newBuilder() + .setApnsTokenRequest(SetPushTokenRequest.ApnsTokenRequest.newBuilder().build()) + .build()), + + Arguments.of(SetPushTokenRequest.newBuilder() + .setFcmTokenRequest(SetPushTokenRequest.FcmTokenRequest.newBuilder().build()) + .build()) + ); + } + + @ParameterizedTest + @MethodSource + void clearPushToken(final long deviceId, + @Nullable final String apnsToken, + @Nullable final String apnsVoipToken, + @Nullable final String fcmToken, + @Nullable final String expectedUserAgent) { + + mockAuthenticationInterceptor.setAuthenticatedDevice(AUTHENTICATED_ACI, deviceId); + + final Device device = mock(Device.class); + when(device.getId()).thenReturn(deviceId); + when(device.isMaster()).thenReturn(deviceId == Device.MASTER_ID); + when(device.getApnId()).thenReturn(apnsToken); + when(device.getVoipApnId()).thenReturn(apnsVoipToken); + when(device.getGcmId()).thenReturn(fcmToken); + when(authenticatedAccount.getDevice(deviceId)).thenReturn(Optional.of(device)); + + final ClearPushTokenResponse ignored = devicesStub.clearPushToken(ClearPushTokenRequest.newBuilder().build()); + + verify(device).setApnId(null); + verify(device).setVoipApnId(null); + verify(device).setGcmId(null); + verify(device).setFetchesMessages(true); + + if (expectedUserAgent != null) { + verify(device).setUserAgent(expectedUserAgent); + } else { + verify(device, never()).setUserAgent(any()); + } + } + + private static Stream clearPushToken() { + return Stream.of( + Arguments.of(Device.MASTER_ID, "apns-token", null, null, "OWI"), + Arguments.of(Device.MASTER_ID, "apns-token", "apns-voip-token", null, "OWI"), + Arguments.of(Device.MASTER_ID, null, "apns-voip-token", null, "OWI"), + Arguments.of(Device.MASTER_ID, null, null, "fcm-token", "OWA"), + Arguments.of(Device.MASTER_ID, null, null, null, null), + Arguments.of(Device.MASTER_ID + 1, "apns-token", null, null, "OWP"), + Arguments.of(Device.MASTER_ID + 1, "apns-token", "apns-voip-token", null, "OWP"), + Arguments.of(Device.MASTER_ID + 1, null, "apns-voip-token", null, "OWP"), + Arguments.of(Device.MASTER_ID + 1, null, null, "fcm-token", "OWA"), + Arguments.of(Device.MASTER_ID + 1, null, null, null, null) + ); + } + + @CartesianTest + void setCapabilities( + @CartesianTest.Values(longs = {Device.MASTER_ID, Device.MASTER_ID + 1}) final long deviceId, + @CartesianTest.Values(booleans = {true, false}) final boolean storage, + @CartesianTest.Values(booleans = {true, false}) final boolean transfer, + @CartesianTest.Values(booleans = {true, false}) final boolean pni, + @CartesianTest.Values(booleans = {true, false}) final boolean paymentActivation) { + + mockAuthenticationInterceptor.setAuthenticatedDevice(AUTHENTICATED_ACI, deviceId); + + final Device device = mock(Device.class); + when(authenticatedAccount.getDevice(deviceId)).thenReturn(Optional.of(device)); + + final SetCapabilitiesResponse ignored = devicesStub.setCapabilities(SetCapabilitiesRequest.newBuilder() + .setStorage(storage) + .setTransfer(transfer) + .setPni(pni) + .setPaymentActivation(paymentActivation) + .build()); + + final Device.DeviceCapabilities expectedCapabilities = new Device.DeviceCapabilities( + storage, + transfer, + pni, + paymentActivation); + + verify(device).setCapabilities(expectedCapabilities); + } +} diff --git a/spam-filter b/spam-filter index 72ccbf5d5..5a6f51288 160000 --- a/spam-filter +++ b/spam-filter @@ -1 +1 @@ -Subproject commit 72ccbf5d5da9ebbd30f84ad5bd877f1a1dbd7e86 +Subproject commit 5a6f51288da430646f056c92b03c367ef0c45135