From a4a420476200cc1242a96adad839b85075752e7b Mon Sep 17 00:00:00 2001 From: ravi-signal <99042880+ravi-signal@users.noreply.github.com> Date: Mon, 13 Nov 2023 10:41:23 -0600 Subject: [PATCH] Allow re-registered accounts to reclaim their usernames --- .../textsecuregcm/storage/Accounts.java | 262 ++++++++++++++---- .../textsecuregcm/storage/AccountsTest.java | 121 +++++++- 2 files changed, 329 insertions(+), 54 deletions(-) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java index 7b86260f5..b558aa436 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java @@ -78,6 +78,8 @@ public class Accounts extends AbstractDynamoDbStore { private static final Logger log = LoggerFactory.getLogger(Accounts.class); + private static final Duration USERNAME_RECLAIM_TTL = Duration.ofDays(3); + static final List ACCOUNT_FIELDS_TO_EXCLUDE_FROM_SERIALIZATION = List.of("uuid", "usernameLinkHandle"); private static final ObjectWriter ACCOUNT_DDB_JSON_WRITER = SystemMapper.jsonMapper() @@ -89,6 +91,7 @@ public class Accounts extends AbstractDynamoDbStore { private static final Timer RESERVE_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "reserveUsername")); private static final Timer CLEAR_USERNAME_HASH_TIMER = Metrics.timer(name(Accounts.class, "clearUsernameHash")); private static final Timer UPDATE_TIMER = Metrics.timer(name(Accounts.class, "update")); + private static final Timer RECLAIM_TIMER = Metrics.timer(name(Accounts.class, "reclaim")); private static final Timer GET_BY_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "getByNumber")); private static final Timer GET_BY_USERNAME_HASH_TIMER = Metrics.timer(name(Accounts.class, "getByUsernameHash")); private static final Timer GET_BY_USERNAME_LINK_HANDLE_TIMER = Metrics.timer(name(Accounts.class, "getByUsernameLinkHandle")); @@ -118,6 +121,8 @@ public class Accounts extends AbstractDynamoDbStore { static final String ATTR_USERNAME_HASH = "N"; // confirmed; bool static final String ATTR_CONFIRMED = "F"; + // reclaimable; bool. Indicates that on confirmation the username link should be preserved + static final String ATTR_RECLAIMABLE = "R"; // unidentified access key; byte[] or null static final String ATTR_UAK = "UAK"; // time to live; number @@ -222,16 +227,9 @@ public class Accounts extends AbstractDynamoDbStore { final ByteBuffer actualAccountUuid = reason.item().get(KEY_ACCOUNT_UUID).b().asByteBuffer(); account.setUuid(UUIDUtil.fromByteBuffer(actualAccountUuid)); - final Account existingAccount = getByAccountIdentifier(account.getUuid()).orElseThrow(); - - // It's up to the client to delete this username hash if they can't retrieve and decrypt the plaintext username from storage service - existingAccount.getUsernameHash().ifPresent(account::setUsernameHash); account.setNumber(existingAccount.getNumber(), existingAccount.getPhoneNumberIdentifier()); - account.setVersion(existingAccount.getVersion()); - - update(account); - + joinAndUnwrapUpdateFuture(reclaimAccount(existingAccount, account)); return false; } @@ -246,11 +244,91 @@ public class Accounts extends AbstractDynamoDbStore { } catch (final JsonProcessingException e) { throw new IllegalArgumentException(e); } - return true; }); } + /** + * Copies over any account attributes that should be preserved when a new account reclaims an account identifier. + * + * @param existingAccount the existing account in the accounts table + * @param accountToCreate a new account, with the same number and identifier as existingAccount + */ + private CompletionStage reclaimAccount(final Account existingAccount, final Account accountToCreate) { + if (!existingAccount.getUuid().equals(accountToCreate.getUuid()) || + !existingAccount.getNumber().equals(accountToCreate.getNumber())) { + throw new IllegalArgumentException("reclaimed accounts must match"); + } + return AsyncTimerUtil.record(RECLAIM_TIMER, () -> { + + accountToCreate.setVersion(existingAccount.getVersion()); + + final List writeItems = new ArrayList<>(); + + // If we're reclaiming an account that already has a username, we'd like to give the re-registering client + // an opportunity to reclaim their original username and link. We do this by: + // 1. marking the usernameHash as reserved for the aci + // 2. saving the username link id, but not the encrypted username. The link will be broken until the client + // reclaims their username + // + // If we partially reclaim the account but fail (for example, we update the account but the client goes away + // before creation is finished), we might be reclaiming the account we already reclaimed. In that case, we + // should copy over the reserved username and link verbatim + if (existingAccount.getReservedUsernameHash().isPresent() && + existingAccount.getUsernameLinkHandle() != null && + existingAccount.getUsernameHash().isEmpty() && + existingAccount.getEncryptedUsername().isEmpty()) { + // reclaiming a partially reclaimed account + accountToCreate.setReservedUsernameHash(existingAccount.getReservedUsernameHash().get()); + accountToCreate.setUsernameLinkHandle(existingAccount.getUsernameLinkHandle()); + } else if (existingAccount.getUsernameHash().isPresent()) { + // reclaiming an account with a username + final byte[] usernameHash = existingAccount.getUsernameHash().get(); + final long expirationTime = clock.instant().plus(USERNAME_RECLAIM_TTL).getEpochSecond(); + accountToCreate.setReservedUsernameHash(usernameHash); + accountToCreate.setUsernameLinkHandle(existingAccount.getUsernameLinkHandle()); + + writeItems.add(TransactWriteItem.builder() + .put(Put.builder() + .tableName(usernamesConstraintTableName) + .item(Map.of( + KEY_ACCOUNT_UUID, AttributeValues.fromUUID(accountToCreate.getUuid()), + ATTR_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash), + ATTR_TTL, AttributeValues.fromLong(expirationTime), + ATTR_CONFIRMED, AttributeValues.fromBool(false), + ATTR_RECLAIMABLE, AttributeValues.fromBool(true))) + .conditionExpression("attribute_not_exists(#username_hash) OR (#ttl < :now) OR #uuid = :uuid") + .expressionAttributeNames(Map.of( + "#username_hash", ATTR_USERNAME_HASH, + "#ttl", ATTR_TTL, + "#uuid", KEY_ACCOUNT_UUID)) + .expressionAttributeValues(Map.of( + ":now", AttributeValues.fromLong(clock.instant().getEpochSecond()), + ":uuid", AttributeValues.fromUUID(accountToCreate.getUuid()))) + .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD) + .build()) + .build()); + } + writeItems.add(UpdateAccountSpec.forAccount(accountsTableName, accountToCreate).transactItem()); + + return asyncClient.transactWriteItems(TransactWriteItemsRequest.builder().transactItems(writeItems).build()) + .thenApply(response -> { + accountToCreate.setVersion(accountToCreate.getVersion() + 1); + return (Void) null; + }) + .exceptionally(throwable -> { + final Throwable unwrapped = ExceptionUtils.unwrap(throwable); + if (unwrapped instanceof TransactionCanceledException te) { + if (te.cancellationReasons().stream().anyMatch(Accounts::conditionalCheckFailed)) { + throw new ContestedOptimisticLockException(); + } + } + // rethrow + throw CompletableFutureUtils.errorAsCompletionException(throwable); + }); + }); + } + /** * Changes the phone number for the given account. The given account's number should be its current, pre-change * number. If this method succeeds, the account's number will be changed to the new number and its phone number @@ -369,7 +447,8 @@ public class Accounts extends AbstractDynamoDbStore { KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid), ATTR_USERNAME_HASH, AttributeValues.fromByteArray(reservedUsernameHash), ATTR_TTL, AttributeValues.fromLong(expirationTime), - ATTR_CONFIRMED, AttributeValues.fromBool(false))) + ATTR_CONFIRMED, AttributeValues.fromBool(false), + ATTR_RECLAIMABLE, AttributeValues.fromBool(false))) .conditionExpression("attribute_not_exists(#username_hash) OR (#ttl < :now)") .expressionAttributeNames(Map.of("#username_hash", ATTR_USERNAME_HASH, "#ttl", ATTR_TTL)) .expressionAttributeValues(Map.of(":now", AttributeValues.fromLong(clock.instant().getEpochSecond()))) @@ -426,32 +505,36 @@ public class Accounts extends AbstractDynamoDbStore { */ public CompletableFuture confirmUsernameHash(final Account account, final byte[] usernameHash, @Nullable final byte[] encryptedUsername) { final Timer.Sample sample = Timer.start(); - final UUID newLinkHandle = UUID.randomUUID(); - final TransactWriteItemsRequest request; + return pickLinkHandle(account, usernameHash) + .thenCompose(linkHandle -> { + final TransactWriteItemsRequest request; + try { + final Account updatedAccount = AccountUtil.cloneAccountAsNotStale(account); + updatedAccount.setUsernameHash(usernameHash); + updatedAccount.setReservedUsernameHash(null); + updatedAccount.setUsernameLinkDetails(encryptedUsername == null ? null : linkHandle, encryptedUsername); - try { - final Account updatedAccount = AccountUtil.cloneAccountAsNotStale(account); - updatedAccount.setUsernameHash(usernameHash); - updatedAccount.setReservedUsernameHash(null); - updatedAccount.setUsernameLinkDetails(encryptedUsername == null ? null : newLinkHandle, encryptedUsername); + request = buildConfirmUsernameHashRequest(updatedAccount, account.getUsernameHash()); + } catch (final JsonProcessingException e) { + throw new IllegalArgumentException(e); + } - request = buildConfirmUsernameHashRequest(updatedAccount, account.getUsernameHash()); - } catch (final JsonProcessingException e) { - throw new IllegalArgumentException(e); - } - - return asyncClient.transactWriteItems(request) - .thenRun(() -> { + return asyncClient.transactWriteItems(request).thenApply(ignored -> linkHandle); + }) + .thenApply(linkHandle -> { account.setUsernameHash(usernameHash); account.setReservedUsernameHash(null); - account.setUsernameLinkDetails(encryptedUsername == null ? null : newLinkHandle, encryptedUsername); + account.setUsernameLinkDetails(encryptedUsername == null ? null : linkHandle, encryptedUsername); account.setVersion(account.getVersion() + 1); + return (Void) null; }) .exceptionally(throwable -> { - if (ExceptionUtils.unwrap(throwable) instanceof TransactionCanceledException transactionCanceledException) { - if (transactionCanceledException.cancellationReasons().stream().map(CancellationReason::code).anyMatch(CONDITIONAL_CHECK_FAILED::equals)) { + if (ExceptionUtils.unwrap( + throwable) instanceof TransactionCanceledException transactionCanceledException) { + if (transactionCanceledException.cancellationReasons().stream().map(CancellationReason::code) + .anyMatch(CONDITIONAL_CHECK_FAILED::equals)) { throw new ContestedOptimisticLockException(); } } @@ -461,7 +544,31 @@ public class Accounts extends AbstractDynamoDbStore { .whenComplete((ignored, throwable) -> sample.stop(SET_USERNAME_TIMER)); } - private TransactWriteItemsRequest buildConfirmUsernameHashRequest(final Account updatedAccount, final Optional maybeOriginalUsernameHash) + private CompletableFuture pickLinkHandle(final Account account, final byte[] usernameHash) { + if (account.getUsernameLinkHandle() == null) { + // There's no old link handle, so we can just use a randomly generated link handle + return CompletableFuture.completedFuture(UUID.randomUUID()); + } + + // Otherwise, there's an existing link handle. If this is the result of an account being re-registered, we should + // preserve the link handle. + return asyncClient.getItem(GetItemRequest.builder() + .tableName(usernamesConstraintTableName) + .key(Map.of(ATTR_USERNAME_HASH, AttributeValues.b(usernameHash))) + .projectionExpression(ATTR_RECLAIMABLE).build()) + .thenApply(response -> { + if (response.hasItem() && AttributeValues.getBool(response.item(), ATTR_RECLAIMABLE, false)) { + // this username reservation indicates it's a username waiting to be "reclaimed" + return account.getUsernameLinkHandle(); + } + // There was no existing username reservation, or this was a standard "new" username. Either way, we should + // generate a new link handle. + return UUID.randomUUID(); + }); + } + + private TransactWriteItemsRequest buildConfirmUsernameHashRequest(final Account updatedAccount, + final Optional maybeOriginalUsernameHash) throws JsonProcessingException { final List writeItems = new ArrayList<>(); @@ -593,10 +700,42 @@ public class Accounts extends AbstractDynamoDbStore { .build(); } - @Nonnull - public CompletionStage updateAsync(final Account account) { - return AsyncTimerUtil.record(UPDATE_TIMER, () -> { - final UpdateItemRequest updateItemRequest; + + /** + * A ddb update that can be used as part of a transaction or single-item update statement. + */ + record UpdateAccountSpec( + String tableName, + Map key, + Map attrNames, + Map attrValues, + String updateExpression, + String conditionExpression) { + UpdateItemRequest updateItemRequest() { + return UpdateItemRequest.builder() + .tableName(tableName) + .key(key) + .updateExpression(updateExpression) + .conditionExpression(conditionExpression) + .expressionAttributeNames(attrNames) + .expressionAttributeValues(attrValues) + .build(); + } + + TransactWriteItem transactItem() { + return TransactWriteItem.builder().update(Update.builder() + .tableName(tableName) + .key(key) + .updateExpression(updateExpression) + .conditionExpression(conditionExpression) + .expressionAttributeNames(attrNames) + .expressionAttributeValues(attrValues) + .build()).build(); + } + + static UpdateAccountSpec forAccount( + final String accountTableName, + final Account account) { try { // username, e164, and pni cannot be modified through this method final Map attrNames = new HashMap<>(Map.of( @@ -618,28 +757,53 @@ public class Accounts extends AbstractDynamoDbStore { attrValues.put(":uak", AttributeValues.fromByteArray(account.getUnidentifiedAccessKey().get())); updateExpressionBuilder.append(", #uak = :uak"); } + + // If the account has a username/handle pair, we should add it to the top level attributes. + // When we remove an encryptedUsername but preserve the link (re-registration), it's possible that the account + // has a usernameLinkHandle but not an encrypted username. In this case there should already be a top-level + // usernameLink attribute. if (account.getEncryptedUsername().isPresent() && account.getUsernameLinkHandle() != null) { attrNames.put("#ul", ATTR_USERNAME_LINK_UUID); attrValues.put(":ul", AttributeValues.fromUUID(account.getUsernameLinkHandle())); updateExpressionBuilder.append(", #ul = :ul"); } - updateExpressionBuilder.append(" ADD #version :version_increment"); - if (account.getEncryptedUsername().isEmpty() || account.getUsernameLinkHandle() == null) { - attrNames.put("#ul", ATTR_USERNAME_LINK_UUID); - updateExpressionBuilder.append(" REMOVE #ul"); - } - updateItemRequest = UpdateItemRequest.builder() - .tableName(accountsTableName) - .key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()))) - .updateExpression(updateExpressionBuilder.toString()) - .conditionExpression("attribute_exists(#number) AND #version = :version") - .expressionAttributeNames(attrNames) - .expressionAttributeValues(attrValues) - .build(); + // Some operations may remove the usernameLink or the usernameHash (re-registration, clear username link, and + // clear username hash). Since these also have top-level ddb attributes, we need to make sure to remove those + // as well. + final List removes = new ArrayList<>(); + if (account.getUsernameLinkHandle() == null) { + attrNames.put("#ul", ATTR_USERNAME_LINK_UUID); + removes.add("#ul"); + } + if (account.getUsernameHash().isEmpty()) { + attrNames.put("#username_hash", ATTR_USERNAME_HASH); + removes.add("#username_hash"); + } + if (!removes.isEmpty()) { + updateExpressionBuilder.append(" REMOVE %s".formatted(String.join(",", removes))); + } + updateExpressionBuilder.append(" ADD #version :version_increment"); + + return new UpdateAccountSpec( + accountTableName, + Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())), + attrNames, + attrValues, + updateExpressionBuilder.toString(), + "attribute_exists(#number) AND #version = :version"); } catch (final JsonProcessingException e) { throw new IllegalArgumentException(e); } + } + } + + @Nonnull + public CompletionStage updateAsync(final Account account) { + return AsyncTimerUtil.record(UPDATE_TIMER, () -> { + final UpdateItemRequest updateItemRequest = UpdateAccountSpec + .forAccount(accountsTableName, account) + .updateItemRequest(); return asyncClient.updateItem(updateItemRequest) .thenApply(response -> { @@ -662,9 +826,9 @@ public class Accounts extends AbstractDynamoDbStore { }); } - public void update(final Account account) throws ContestedOptimisticLockException { + private static void joinAndUnwrapUpdateFuture(CompletionStage future) { try { - updateAsync(account).toCompletableFuture().join(); + future.toCompletableFuture().join(); } catch (final CompletionException e) { // unwrap CompletionExceptions, throw as long is it's unchecked Throwables.throwIfUnchecked(ExceptionUtils.unwrap(e)); @@ -676,6 +840,10 @@ public class Accounts extends AbstractDynamoDbStore { } } + public void update(final Account account) throws ContestedOptimisticLockException { + joinAndUnwrapUpdateFuture(updateAsync(account)); + } + public CompletableFuture usernameHashAvailable(final byte[] username) { return usernameHashAvailable(Optional.empty(), username); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java index d9dd959d2..e5da5aa40 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java @@ -34,6 +34,7 @@ import java.util.Random; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.stream.Collectors; @@ -45,6 +46,7 @@ import org.junit.jupiter.api.Timeout; 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.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; @@ -323,6 +325,75 @@ class AccountsTest { verifyStoredState("+14151112222", uuid, null, null, retrieved.get(), account); } + // State before the account is re-registered + enum UsernameStatus { + NONE, + RESERVED, + RESERVED_WITH_SAVED_LINK, + CONFIRMED + } + + @ParameterizedTest + @EnumSource(UsernameStatus.class) + void reclaimAccountWithNoUsername(UsernameStatus usernameStatus) { + Device device = generateDevice(DEVICE_ID_1); + UUID firstUuid = UUID.randomUUID(); + UUID firstPni = UUID.randomUUID(); + Account account = generateAccount("+14151112222", firstUuid, firstPni, List.of(device)); + accounts.create(account); + + final byte[] usernameHash = randomBytes(32); + final byte[] encryptedUsername = randomBytes(32); + switch (usernameStatus) { + case NONE: + break; + case RESERVED: + accounts.reserveUsernameHash(account, randomBytes(32), Duration.ofMinutes(1)).join(); + break; + case RESERVED_WITH_SAVED_LINK: + // give the account a username + accounts.reserveUsernameHash(account, usernameHash, Duration.ofMinutes(1)).join(); + accounts.confirmUsernameHash(account, usernameHash, encryptedUsername).join(); + + // simulate a failed re-reg: we give the account a reclaimable username, but we'll try + // re-registering again later in the test case + account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1))); + accounts.create(account); + break; + case CONFIRMED: + accounts.reserveUsernameHash(account, usernameHash, Duration.ofMinutes(1)).join(); + accounts.confirmUsernameHash(account, usernameHash, encryptedUsername).join(); + break; + } + + Optional preservedLink = Optional.ofNullable(account.getUsernameLinkHandle()); + + // re-register the account + account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1))); + accounts.create(account); + + // If we had a username link, or we had previously saved a username link from another re-registration, make sure + // we preserve it + accounts.confirmUsernameHash(account, usernameHash, encryptedUsername).join(); + + boolean shouldReuseLink = switch (usernameStatus) { + case RESERVED_WITH_SAVED_LINK, CONFIRMED -> true; + case NONE, RESERVED -> false; + }; + + // If we had a reclaimable username, make sure we preserved the link. + assertThat(account.getUsernameLinkHandle().equals(preservedLink.orElse(null))) + .isEqualTo(shouldReuseLink); + + + // in all cases, we should now have usernameHash, usernameLink, and encryptedUsername set + assertThat(account.getUsernameHash()).isNotEmpty(); + assertThat(account.getEncryptedUsername()).isNotEmpty(); + assertThat(account.getUsernameLinkHandle()).isNotNull(); + assertThat(account.getReservedUsernameHash()).isEmpty(); + + } + @Test void testOverwrite() { Device device = generateDevice(DEVICE_ID_1); @@ -332,14 +403,12 @@ class AccountsTest { accounts.create(account); - final SecureRandom byteGenerator = new SecureRandom(); - final byte[] usernameHash = new byte[32]; - byteGenerator.nextBytes(usernameHash); - final byte[] encryptedUsername = new byte[16]; - byteGenerator.nextBytes(encryptedUsername); + final byte[] usernameHash = randomBytes(32); + final byte[] encryptedUsername = randomBytes(16); // Set up the existing account to have a username hash accounts.confirmUsernameHash(account, usernameHash, encryptedUsername).join(); + final UUID usernameLinkHandle = account.getUsernameLinkHandle(); verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), usernameHash, account, true); @@ -355,14 +424,35 @@ class AccountsTest { final boolean freshUser = accounts.create(account); assertThat(freshUser).isFalse(); - verifyStoredState("+14151112222", firstUuid, firstPni, usernameHash, account, true); + // usernameHash should be unset + verifyStoredState("+14151112222", firstUuid, firstPni, null, account, true); + + // username should become 'reclaimable' + Map item = readAccount(firstUuid); + Account result = Accounts.fromItem(item); + assertThat(AttributeValues.getUUID(item, Accounts.ATTR_USERNAME_LINK_UUID, null)) + .isEqualTo(usernameLinkHandle) + .isEqualTo(result.getUsernameLinkHandle()); + assertThat(result.getUsernameHash()).isEmpty(); + assertThat(result.getEncryptedUsername()).isEmpty(); + assertArrayEquals(result.getReservedUsernameHash().get(), usernameHash); + + // should keep the same usernameLink, now encryptedUsername should be set + accounts.confirmUsernameHash(result, usernameHash, encryptedUsername).join(); + item = readAccount(firstUuid); + result = Accounts.fromItem(item); + assertThat(AttributeValues.getUUID(item, Accounts.ATTR_USERNAME_LINK_UUID, null)) + .isEqualTo(usernameLinkHandle) + .isEqualTo(result.getUsernameLinkHandle()); + assertArrayEquals(result.getEncryptedUsername().get(), encryptedUsername); + assertArrayEquals(result.getUsernameHash().get(), usernameHash); + assertThat(result.getReservedUsernameHash()).isEmpty(); assertPhoneNumberConstraintExists("+14151112222", firstUuid); assertPhoneNumberIdentifierConstraintExists(firstPni, firstUuid); device = generateDevice(DEVICE_ID_1); Account invalidAccount = generateAccount("+14151113333", firstUuid, UUID.randomUUID(), List.of(device)); - assertThatThrownBy(() -> accounts.create(invalidAccount)); } @@ -1078,6 +1168,17 @@ class AccountsTest { assertThat(pniConstraintResponse.hasItem()).isFalse(); } + private Map readAccount(final UUID uuid) { + final DynamoDbClient db = DYNAMO_DB_EXTENSION.getDynamoDbClient(); + + final GetItemResponse get = db.getItem(GetItemRequest.builder() + .tableName(Tables.ACCOUNTS.tableName()) + .key(Map.of(Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid))) + .consistentRead(true) + .build()); + return get.item(); + } + private void verifyStoredState(String number, UUID uuid, UUID pni, byte[] usernameHash, Account expecting, boolean canonicallyDiscoverable) { final DynamoDbClient db = DYNAMO_DB_EXTENSION.getDynamoDbClient(); @@ -1131,4 +1232,10 @@ class AccountsTest { assertThat(resultDevice.getCreated()).isEqualTo(expectingDevice.getCreated()); } } + + private static byte[] randomBytes(int count) { + byte[] bytes = new byte[count]; + ThreadLocalRandom.current().nextBytes(bytes); + return bytes; + } }