Reuse account UUIDs when registering an account with a recently-deleted e164.
This commit is contained in:
parent
be20c04cd8
commit
3a966ef345
|
@ -331,8 +331,7 @@ public class AccountController {
|
||||||
@HeaderParam("User-Agent") String userAgent,
|
@HeaderParam("User-Agent") String userAgent,
|
||||||
@QueryParam("transfer") Optional<Boolean> availableForTransfer,
|
@QueryParam("transfer") Optional<Boolean> availableForTransfer,
|
||||||
@Valid AccountAttributes accountAttributes)
|
@Valid AccountAttributes accountAttributes)
|
||||||
throws RateLimitExceededException
|
throws RateLimitExceededException, InterruptedException {
|
||||||
{
|
|
||||||
try {
|
try {
|
||||||
AuthorizationHeader header = AuthorizationHeader.fromFullHeader(authorizationHeader);
|
AuthorizationHeader header = AuthorizationHeader.fromFullHeader(authorizationHeader);
|
||||||
String number = header.getIdentifier().getNumber();
|
String number = header.getIdentifier().getNumber();
|
||||||
|
|
|
@ -143,91 +143,107 @@ public class AccountsManager {
|
||||||
public Account create(final String number,
|
public Account create(final String number,
|
||||||
final String password,
|
final String password,
|
||||||
final String signalAgent,
|
final String signalAgent,
|
||||||
final AccountAttributes accountAttributes) {
|
final AccountAttributes accountAttributes) throws InterruptedException {
|
||||||
|
|
||||||
try (Timer.Context ignored = createTimer.time()) {
|
try (Timer.Context ignored = createTimer.time()) {
|
||||||
Optional<Account> maybeExistingAccount = get(number);
|
final Account account = new Account();
|
||||||
|
|
||||||
Device device = new Device();
|
deletedAccountsManager.lockAndTake(number, maybeRecentlyDeletedUuid -> {
|
||||||
device.setId(Device.MASTER_ID);
|
Device device = new Device();
|
||||||
device.setAuthenticationCredentials(new AuthenticationCredentials(password));
|
device.setId(Device.MASTER_ID);
|
||||||
device.setFetchesMessages(accountAttributes.getFetchesMessages());
|
device.setAuthenticationCredentials(new AuthenticationCredentials(password));
|
||||||
device.setRegistrationId(accountAttributes.getRegistrationId());
|
device.setFetchesMessages(accountAttributes.getFetchesMessages());
|
||||||
device.setName(accountAttributes.getName());
|
device.setRegistrationId(accountAttributes.getRegistrationId());
|
||||||
device.setCapabilities(accountAttributes.getCapabilities());
|
device.setName(accountAttributes.getName());
|
||||||
device.setCreated(System.currentTimeMillis());
|
device.setCapabilities(accountAttributes.getCapabilities());
|
||||||
device.setLastSeen(Util.todayInMillis());
|
device.setCreated(System.currentTimeMillis());
|
||||||
device.setUserAgent(signalAgent);
|
device.setLastSeen(Util.todayInMillis());
|
||||||
|
device.setUserAgent(signalAgent);
|
||||||
|
|
||||||
Account account = new Account();
|
account.setNumber(number);
|
||||||
account.setNumber(number);
|
account.setUuid(maybeRecentlyDeletedUuid.orElseGet(UUID::randomUUID));
|
||||||
account.setUuid(UUID.randomUUID());
|
account.addDevice(device);
|
||||||
account.addDevice(device);
|
account.setRegistrationLockFromAttributes(accountAttributes);
|
||||||
account.setRegistrationLockFromAttributes(accountAttributes);
|
account.setUnidentifiedAccessKey(accountAttributes.getUnidentifiedAccessKey());
|
||||||
account.setUnidentifiedAccessKey(accountAttributes.getUnidentifiedAccessKey());
|
account.setUnrestrictedUnidentifiedAccess(accountAttributes.isUnrestrictedUnidentifiedAccess());
|
||||||
account.setUnrestrictedUnidentifiedAccess(accountAttributes.isUnrestrictedUnidentifiedAccess());
|
account.setDiscoverableByPhoneNumber(accountAttributes.isDiscoverableByPhoneNumber());
|
||||||
account.setDiscoverableByPhoneNumber(accountAttributes.isDiscoverableByPhoneNumber());
|
|
||||||
|
|
||||||
final UUID originalUuid = account.getUuid();
|
final UUID originalUuid = account.getUuid();
|
||||||
boolean freshUser = databaseCreate(account);
|
|
||||||
|
|
||||||
// databaseCreate() sometimes updates the UUID, if there was a number conflict.
|
boolean freshUser = databaseCreate(account);
|
||||||
// for metrics, we want dynamo to run with the same original UUID
|
|
||||||
final UUID actualUuid = account.getUuid();
|
|
||||||
|
|
||||||
try {
|
// databaseCreate() sometimes updates the UUID, if there was a number conflict.
|
||||||
if (dynamoWriteEnabled()) {
|
// for metrics, we want dynamo to run with the same original UUID
|
||||||
|
final UUID actualUuid = account.getUuid();
|
||||||
|
|
||||||
account.setUuid(originalUuid);
|
try {
|
||||||
|
if (dynamoWriteEnabled()) {
|
||||||
|
|
||||||
runSafelyAndRecordMetrics(() -> dynamoCreate(account), Optional.of(account.getUuid()), freshUser,
|
account.setUuid(originalUuid);
|
||||||
(databaseResult, dynamoResult) -> {
|
|
||||||
|
|
||||||
if (!account.getUuid().equals(actualUuid)) {
|
runSafelyAndRecordMetrics(() -> dynamoCreate(account), Optional.of(account.getUuid()), freshUser,
|
||||||
logger.warn("dynamoCreate() did not return correct UUID");
|
(databaseResult, dynamoResult) -> {
|
||||||
}
|
|
||||||
|
|
||||||
if (databaseResult.equals(dynamoResult)) {
|
if (!account.getUuid().equals(actualUuid)) {
|
||||||
return Optional.empty();
|
logger.warn("dynamoCreate() did not return correct UUID");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dynamoResult) {
|
if (databaseResult.equals(dynamoResult)) {
|
||||||
return Optional.of("dynamoFreshUser");
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Optional.of("dbFreshUser");
|
if (dynamoResult) {
|
||||||
},
|
return Optional.of("dynamoFreshUser");
|
||||||
"create");
|
}
|
||||||
|
|
||||||
|
return Optional.of("dbFreshUser");
|
||||||
|
},
|
||||||
|
"create");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
account.setUuid(actualUuid);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
account.setUuid(actualUuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
redisSet(account);
|
redisSet(account);
|
||||||
|
|
||||||
final Tags tags;
|
pendingAccounts.remove(number);
|
||||||
|
|
||||||
if (freshUser) {
|
// In terms of previously-existing accounts, there are three possible cases:
|
||||||
tags = Tags.of("type", "new");
|
//
|
||||||
newUserMeter.mark();
|
// 1. This is a completely new account; there was no pre-existing account and no recently-deleted account
|
||||||
} else {
|
// 2. This is a re-registration of an existing account. The storage layer will update the existing account in
|
||||||
tags = Tags.of("type", "reregister");
|
// place to match the account record created above, and will update the UUID of the newly-created account
|
||||||
}
|
// instance to match the stored account record (i.e. originalUuid != actualUuid).
|
||||||
|
// 3. This is a re-registration of a recently-deleted account, in which case maybeRecentlyDeletedUuid is
|
||||||
|
// present.
|
||||||
|
//
|
||||||
|
// All cases are mutually-exclusive. In the first case, we don't need to do anything. In the third, we can be
|
||||||
|
// confident that everything has already been deleted. In the second case, though, we're taking over an existing
|
||||||
|
// account and need to clear out messages and keys that may have been stored for the old account.
|
||||||
|
if (!originalUuid.equals(actualUuid)) {
|
||||||
|
messagesManager.clear(actualUuid);
|
||||||
|
keysDynamoDb.delete(actualUuid);
|
||||||
|
}
|
||||||
|
|
||||||
Metrics.counter(CREATE_COUNTER_NAME, tags).increment();
|
final Tags tags;
|
||||||
|
|
||||||
if (!account.isDiscoverableByPhoneNumber()) {
|
if (freshUser) {
|
||||||
// The newly-created account has explicitly opted out of discoverability
|
tags = Tags.of("type", "new");
|
||||||
directoryQueue.deleteAccount(account);
|
newUserMeter.mark();
|
||||||
}
|
} else if (!originalUuid.equals(actualUuid)) {
|
||||||
|
tags = Tags.of("type", "re-registration");
|
||||||
|
} else {
|
||||||
|
tags = Tags.of("type", "recently-deleted");
|
||||||
|
}
|
||||||
|
|
||||||
maybeExistingAccount.ifPresent(definitelyExistingAccount -> {
|
Metrics.counter(CREATE_COUNTER_NAME, tags).increment();
|
||||||
messagesManager.clear(definitelyExistingAccount.getUuid());
|
|
||||||
keysDynamoDb.delete(definitelyExistingAccount.getUuid());
|
if (!account.isDiscoverableByPhoneNumber()) {
|
||||||
|
// The newly-created account has explicitly opted out of discoverability
|
||||||
|
directoryQueue.deleteAccount(account);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
pendingAccounts.remove(number);
|
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.atLeast;
|
import static org.mockito.Mockito.atLeast;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
@ -22,11 +23,9 @@ import com.opentable.db.postgres.junit5.PreparedDbExtension;
|
||||||
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Random;
|
import java.util.Random;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
|
@ -88,7 +87,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
|
||||||
private Executor mutationExecutor = new ThreadPoolExecutor(20, 20, 5, TimeUnit.SECONDS, new LinkedBlockingDeque<>(20));
|
private Executor mutationExecutor = new ThreadPoolExecutor(20, 20, 5, TimeUnit.SECONDS, new LinkedBlockingDeque<>(20));
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setup() {
|
void setup() throws InterruptedException {
|
||||||
|
|
||||||
{
|
{
|
||||||
CreateTableRequest createNumbersTableRequest = CreateTableRequest.builder()
|
CreateTableRequest createNumbersTableRequest = CreateTableRequest.builder()
|
||||||
|
@ -145,11 +144,19 @@ class AccountsManagerConcurrentModificationIntegrationTest {
|
||||||
|
|
||||||
commands = mock(RedisAdvancedClusterCommands.class);
|
commands = mock(RedisAdvancedClusterCommands.class);
|
||||||
|
|
||||||
|
final DeletedAccountsManager deletedAccountsManager = mock(DeletedAccountsManager.class);
|
||||||
|
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
//noinspection unchecked
|
||||||
|
invocation.getArgument(1, Consumer.class).accept(Optional.empty());
|
||||||
|
return null;
|
||||||
|
}).when(deletedAccountsManager).lockAndTake(anyString(), any());
|
||||||
|
|
||||||
accountsManager = new AccountsManager(
|
accountsManager = new AccountsManager(
|
||||||
accounts,
|
accounts,
|
||||||
accountsDynamoDb,
|
accountsDynamoDb,
|
||||||
RedisClusterHelper.buildMockRedisCluster(commands),
|
RedisClusterHelper.buildMockRedisCluster(commands),
|
||||||
mock(DeletedAccountsManager.class),
|
deletedAccountsManager,
|
||||||
mock(DirectoryQueue.class),
|
mock(DirectoryQueue.class),
|
||||||
mock(KeysDynamoDb.class),
|
mock(KeysDynamoDb.class),
|
||||||
mock(MessagesManager.class),
|
mock(MessagesManager.class),
|
||||||
|
@ -164,7 +171,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testConcurrentUpdate() throws IOException {
|
void testConcurrentUpdate() throws IOException, InterruptedException {
|
||||||
|
|
||||||
final UUID uuid;
|
final UUID uuid;
|
||||||
{
|
{
|
||||||
|
|
|
@ -11,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.anyString;
|
import static org.mockito.Mockito.anyString;
|
||||||
import static org.mockito.Mockito.doAnswer;
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
@ -68,9 +69,12 @@ class AccountsManagerTest {
|
||||||
|
|
||||||
private Accounts accounts;
|
private Accounts accounts;
|
||||||
private AccountsDynamoDb accountsDynamoDb;
|
private AccountsDynamoDb accountsDynamoDb;
|
||||||
|
private DeletedAccountsManager deletedAccountsManager;
|
||||||
private DirectoryQueue directoryQueue;
|
private DirectoryQueue directoryQueue;
|
||||||
private DynamicConfigurationManager dynamicConfigurationManager;
|
private DynamicConfigurationManager dynamicConfigurationManager;
|
||||||
private ExperimentEnrollmentManager experimentEnrollmentManager;
|
private ExperimentEnrollmentManager experimentEnrollmentManager;
|
||||||
|
private KeysDynamoDb keys;
|
||||||
|
private MessagesManager messagesManager;
|
||||||
|
|
||||||
private RedisAdvancedClusterCommands<String, String> commands;
|
private RedisAdvancedClusterCommands<String, String> commands;
|
||||||
private AccountsManager accountsManager;
|
private AccountsManager accountsManager;
|
||||||
|
@ -84,12 +88,15 @@ class AccountsManagerTest {
|
||||||
};
|
};
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setup() {
|
void setup() throws InterruptedException {
|
||||||
accounts = mock(Accounts.class);
|
accounts = mock(Accounts.class);
|
||||||
accountsDynamoDb = mock(AccountsDynamoDb.class);
|
accountsDynamoDb = mock(AccountsDynamoDb.class);
|
||||||
|
deletedAccountsManager = mock(DeletedAccountsManager.class);
|
||||||
directoryQueue = mock(DirectoryQueue.class);
|
directoryQueue = mock(DirectoryQueue.class);
|
||||||
dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
|
dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
|
||||||
experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class);
|
experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class);
|
||||||
|
keys = mock(KeysDynamoDb.class);
|
||||||
|
messagesManager = mock(MessagesManager.class);
|
||||||
|
|
||||||
//noinspection unchecked
|
//noinspection unchecked
|
||||||
commands = mock(RedisAdvancedClusterCommands.class);
|
commands = mock(RedisAdvancedClusterCommands.class);
|
||||||
|
@ -97,14 +104,20 @@ class AccountsManagerTest {
|
||||||
final DynamicConfiguration dynamicConfiguration = new DynamicConfiguration();
|
final DynamicConfiguration dynamicConfiguration = new DynamicConfiguration();
|
||||||
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
|
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
|
||||||
|
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
//noinspection unchecked
|
||||||
|
invocation.getArgument(1, Consumer.class).accept(Optional.empty());
|
||||||
|
return null;
|
||||||
|
}).when(deletedAccountsManager).lockAndTake(anyString(), any());
|
||||||
|
|
||||||
accountsManager = new AccountsManager(
|
accountsManager = new AccountsManager(
|
||||||
accounts,
|
accounts,
|
||||||
accountsDynamoDb,
|
accountsDynamoDb,
|
||||||
RedisClusterHelper.buildMockRedisCluster(commands),
|
RedisClusterHelper.buildMockRedisCluster(commands),
|
||||||
mock(DeletedAccountsManager.class),
|
deletedAccountsManager,
|
||||||
directoryQueue,
|
directoryQueue,
|
||||||
mock(KeysDynamoDb.class),
|
keys,
|
||||||
mock(MessagesManager.class),
|
messagesManager,
|
||||||
mock(UsernamesManager.class),
|
mock(UsernamesManager.class),
|
||||||
mock(ProfilesManager.class),
|
mock(ProfilesManager.class),
|
||||||
mock(StoredVerificationCodeManager.class),
|
mock(StoredVerificationCodeManager.class),
|
||||||
|
@ -521,9 +534,61 @@ class AccountsManagerTest {
|
||||||
assertEquals(Optional.of("profileName"), accountsManager.compareAccounts(Optional.of(a1), Optional.of(a2)));
|
assertEquals(Optional.of("profileName"), accountsManager.compareAccounts(Optional.of(a1), Optional.of(a2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateFreshAccount() throws InterruptedException {
|
||||||
|
when(accounts.create(any())).thenReturn(true);
|
||||||
|
|
||||||
|
final String e164 = "+18005550123";
|
||||||
|
final AccountAttributes attributes = new AccountAttributes(false, 0, null, null, null, true, null);
|
||||||
|
accountsManager.create(e164, "password", null, attributes);
|
||||||
|
|
||||||
|
verify(accounts).create(argThat(account -> e164.equals(account.getNumber())));
|
||||||
|
verifyNoInteractions(keys);
|
||||||
|
verifyNoInteractions(messagesManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testReregisterAccount() throws InterruptedException {
|
||||||
|
final UUID existingUuid = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(accounts.create(any())).thenAnswer(invocation -> {
|
||||||
|
invocation.getArgument(0, Account.class).setUuid(existingUuid);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
final String e164 = "+18005550123";
|
||||||
|
final AccountAttributes attributes = new AccountAttributes(false, 0, null, null, null, true, null);
|
||||||
|
accountsManager.create(e164, "password", null, attributes);
|
||||||
|
|
||||||
|
verify(accounts).create(argThat(account -> e164.equals(account.getNumber()) && existingUuid.equals(account.getUuid())));
|
||||||
|
verify(keys).delete(existingUuid);
|
||||||
|
verify(messagesManager).clear(existingUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateAccountRecentlyDeleted() throws InterruptedException {
|
||||||
|
final UUID recentlyDeletedUuid = UUID.randomUUID();
|
||||||
|
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
//noinspection unchecked
|
||||||
|
invocation.getArgument(1, Consumer.class).accept(Optional.of(recentlyDeletedUuid));
|
||||||
|
return null;
|
||||||
|
}).when(deletedAccountsManager).lockAndTake(anyString(), any());
|
||||||
|
|
||||||
|
when(accounts.create(any())).thenReturn(true);
|
||||||
|
|
||||||
|
final String e164 = "+18005550123";
|
||||||
|
final AccountAttributes attributes = new AccountAttributes(false, 0, null, null, null, true, null);
|
||||||
|
accountsManager.create(e164, "password", null, attributes);
|
||||||
|
|
||||||
|
verify(accounts).create(argThat(account -> e164.equals(account.getNumber()) && recentlyDeletedUuid.equals(account.getUuid())));
|
||||||
|
verifyNoInteractions(keys);
|
||||||
|
verifyNoInteractions(messagesManager);
|
||||||
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(booleans = {true, false})
|
@ValueSource(booleans = {true, false})
|
||||||
void testCreateWithDiscoverability(final boolean discoverable) {
|
void testCreateWithDiscoverability(final boolean discoverable) throws InterruptedException {
|
||||||
final AccountAttributes attributes = new AccountAttributes(false, 0, null, null, null, discoverable, null);
|
final AccountAttributes attributes = new AccountAttributes(false, 0, null, null, null, discoverable, null);
|
||||||
final Account account = accountsManager.create("+18005550123", "password", null, attributes);
|
final Account account = accountsManager.create("+18005550123", "password", null, attributes);
|
||||||
|
|
||||||
|
@ -536,7 +601,7 @@ class AccountsManagerTest {
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(booleans = {true, false})
|
@ValueSource(booleans = {true, false})
|
||||||
void testCreateWithStorageCapability(final boolean hasStorage) {
|
void testCreateWithStorageCapability(final boolean hasStorage) throws InterruptedException {
|
||||||
final AccountAttributes attributes = new AccountAttributes(false, 0, null, null, null, true,
|
final AccountAttributes attributes = new AccountAttributes(false, 0, null, null, null, true,
|
||||||
new DeviceCapabilities(false, false, false, hasStorage, false, false, false, false));
|
new DeviceCapabilities(false, false, false, hasStorage, false, false, false, false));
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue