Add a gRPC service for working with devices

This commit is contained in:
Jon Chambers 2023-08-07 17:20:12 -04:00 committed by Chris Eager
parent 619b05e56c
commit 754f71ce00
4 changed files with 815 additions and 1 deletions

View File

@ -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<GetDevicesResponse> 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<RemoveDeviceResponse> 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<SetDeviceNameResponse> 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<SetPushTokenResponse> 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<ClearPushTokenResponse> 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<SetCapabilitiesResponse> 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());
}
}

View File

@ -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 {}

View File

@ -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<Account> 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<Device> 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<Arguments> 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<Arguments> setPushToken() {
final String apnsToken = "apns-token";
final String apnsVoipToken = "apns-voip-token";
final String fcmToken = "fcm-token";
final Stream.Builder<Arguments> 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<Arguments> 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<Arguments> 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<Arguments> 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);
}
}

@ -1 +1 @@
Subproject commit 72ccbf5d5da9ebbd30f84ad5bd877f1a1dbd7e86
Subproject commit 5a6f51288da430646f056c92b03c367ef0c45135