Add a gRPC service for working with devices
This commit is contained in:
parent
619b05e56c
commit
754f71ce00
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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
|
Loading…
Reference in New Issue