Use DynamoDB for change number waiting periods

DynamoDB is even simpler for integration tests.
This commit is contained in:
Chris Eager 2026-05-18 16:27:56 -05:00 committed by Chris Eager
parent 23305e4460
commit 460e5cb499
14 changed files with 236 additions and 90 deletions

View File

@ -5,20 +5,14 @@
package org.signal.integration;
import io.lettuce.core.resource.ClientResources;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.signal.integration.config.Config;
import org.whispersystems.textsecuregcm.metrics.NoopAwsSdkMetricPublisher;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;
import org.whispersystems.textsecuregcm.registration.VerificationSession;
import org.whispersystems.textsecuregcm.storage.ChangeNumberWaitingPeriodManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberWaitingPeriods;
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
@ -28,6 +22,7 @@ import org.whispersystems.textsecuregcm.util.Util;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
public class IntegrationTools {
@ -37,7 +32,7 @@ public class IntegrationTools {
private final PhoneNumberIdentifiers phoneNumberIdentifiers;
private final ChangeNumberWaitingPeriodManager changeNumberWaitingPeriodManager;
private final ChangeNumberWaitingPeriods changeNumberWaitingPeriods;
public static IntegrationTools create(final Config config) {
final AwsCredentialsProvider credentialsProvider = DefaultCredentialsProvider.builder().build();
@ -45,22 +40,20 @@ public class IntegrationTools {
final DynamoDbAsyncClient dynamoDbAsyncClient =
config.dynamoDbClient().buildAsyncClient(credentialsProvider, new NoopAwsSdkMetricPublisher());
final DynamoDbClient dynamoDbClient =
config.dynamoDbClient().buildSyncClient(credentialsProvider, new NoopAwsSdkMetricPublisher());
final RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords(
config.dynamoDbTables().registrationRecovery(), Duration.ofDays(1), dynamoDbAsyncClient, Clock.systemUTC());
final VerificationSessions verificationSessions = new VerificationSessions(
dynamoDbAsyncClient, config.dynamoDbTables().verificationSessions(), Clock.systemUTC());
final FaultTolerantRedisClusterClient rateLimitersClient = new FaultTolerantRedisClusterClient(
"rateLimiters",
config.redis().rateLimiters(),
ClientResources.builder());
return new IntegrationTools(
new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords),
new VerificationSessionManager(verificationSessions),
new PhoneNumberIdentifiers(dynamoDbAsyncClient, config.dynamoDbTables().phoneNumberIdentifiers()),
new ChangeNumberWaitingPeriodManager(rateLimitersClient, Duration.ZERO)
new ChangeNumberWaitingPeriods(config.dynamoDbTables().changeNumberWaitingPeriods(), dynamoDbAsyncClient, dynamoDbClient)
);
}
@ -68,11 +61,11 @@ public class IntegrationTools {
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
final VerificationSessionManager verificationSessionManager,
final PhoneNumberIdentifiers phoneNumberIdentifiers,
final ChangeNumberWaitingPeriodManager changeNumberWaitingPeriodManager) {
final ChangeNumberWaitingPeriods changeNumberWaitingPeriods) {
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
this.verificationSessionManager = verificationSessionManager;
this.phoneNumberIdentifiers = phoneNumberIdentifiers;
this.changeNumberWaitingPeriodManager = changeNumberWaitingPeriodManager;
this.changeNumberWaitingPeriods = changeNumberWaitingPeriods;
}
public CompletableFuture<Void> populateRecoveryPassword(final String phoneNumber, final byte[] password) {
@ -88,11 +81,6 @@ public class IntegrationTools {
}
public void clearChangeNumberWaitingPeriod(TestUser user) {
try {
changeNumberWaitingPeriodManager.handleAccountCreated(user.aciUuid(), Instant.now().minus(Duration.ofDays(1)))
.get(5, TimeUnit.SECONDS);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
throw new RuntimeException(e);
}
changeNumberWaitingPeriods.delete(user.aciUuid());
}
}

View File

@ -15,6 +15,5 @@ public record Config(@NotBlank String domain,
@NotNull @Valid DynamoDbClientFactory dynamoDbClient,
@NotNull @Valid DynamoDbTables dynamoDbTables,
@NotBlank String prescribedRegistrationNumber,
@NotBlank String prescribedRegistrationCode,
@NotNull @Valid Redis redis) {
@NotBlank String prescribedRegistrationCode) {
}

View File

@ -9,5 +9,6 @@ import jakarta.validation.constraints.NotBlank;
public record DynamoDbTables(@NotBlank String registrationRecovery,
@NotBlank String verificationSessions,
@NotBlank String phoneNumberIdentifiers) {
@NotBlank String phoneNumberIdentifiers,
@NotBlank String changeNumberWaitingPeriods) {
}

View File

@ -1,13 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.integration.config;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
public record Redis(@NotNull @Valid RedisClusterConfiguration rateLimiters) {
}

View File

@ -92,6 +92,8 @@ dynamoDbTables:
tableName: Example_AppleDeviceCheckPublicKeys
backups:
tableName: Example_Backups
changeNumberWaitingPeriods:
tableName: Example_ChangeNumberWaitingPeriods
clientReleases:
tableName: Example_ClientReleases
deletedAccounts:

View File

@ -236,6 +236,7 @@ import org.whispersystems.textsecuregcm.storage.Accounts;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberWaitingPeriodManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberWaitingPeriods;
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
import org.whispersystems.textsecuregcm.storage.ClientReleases;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
@ -706,8 +707,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new RedisMessageAvailabilityManager(messagesCluster, clientEventExecutor, asyncOperationQueueingExecutor);
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager,
reportMessageManager, messageDeletionAsyncExecutor, Clock.systemUTC());
final ChangeNumberWaitingPeriods changeNumberWaitingPeriods = new ChangeNumberWaitingPeriods(
config.getDynamoDbTables().getChangeNumberWaitingPeriods().getTableName(), dynamoDbAsyncClient, dynamoDbClient);
final ChangeNumberWaitingPeriodManager changeNumberWaitingPeriodManager = new ChangeNumberWaitingPeriodManager(
rateLimitersCluster, config.getChangeNumber().postRegistrationWaitingPeriod());
changeNumberWaitingPeriods, config.getChangeNumber().postRegistrationWaitingPeriod(), clock);
AccountLockManager accountLockManager = new AccountLockManager(dynamoDbClient,
config.getDynamoDbTables().getDeletedAccountsLock().getTableName());
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,

View File

@ -49,6 +49,7 @@ public class DynamoDbTables {
private final AccountsTableConfiguration accounts;
private final Table appleDeviceChecks;
private final Table changeNumberWaitingPeriods;
private final Table appleDeviceCheckPublicKeys;
private final Table backups;
private final Table clientPublicKeys;
@ -78,6 +79,7 @@ public class DynamoDbTables {
public DynamoDbTables(
@JsonProperty("accounts") final AccountsTableConfiguration accounts,
@JsonProperty("appleDeviceChecks") final Table appleDeviceChecks,
@JsonProperty("changeNumberWaitingPeriods") final Table changeNumberWaitingPeriods,
@JsonProperty("appleDeviceCheckPublicKeys") final Table appleDeviceCheckPublicKeys,
@JsonProperty("backups") final Table backups,
@JsonProperty("clientPublicKeys") final Table clientPublicKeys,
@ -106,6 +108,7 @@ public class DynamoDbTables {
this.accounts = accounts;
this.appleDeviceChecks = appleDeviceChecks;
this.changeNumberWaitingPeriods = changeNumberWaitingPeriods;
this.appleDeviceCheckPublicKeys = appleDeviceCheckPublicKeys;
this.backups = backups;
this.clientPublicKeys = clientPublicKeys;
@ -145,6 +148,12 @@ public class DynamoDbTables {
return appleDeviceChecks;
}
@NotNull
@Valid
public Table getChangeNumberWaitingPeriods() {
return changeNumberWaitingPeriods;
}
@NotNull
@Valid
public Table getAppleDeviceCheckPublicKeys() {

View File

@ -6,59 +6,39 @@
package org.whispersystems.textsecuregcm.storage;
import com.google.common.annotations.VisibleForTesting;
import io.lettuce.core.SetArgs;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;
/// Manages post-registration change number waiting period expiration data
public class ChangeNumberWaitingPeriodManager {
private static final Logger LOGGER = LoggerFactory.getLogger(ChangeNumberWaitingPeriodManager.class);
private final FaultTolerantRedisClusterClient redisCluster;
private final ChangeNumberWaitingPeriods changeNumberWaitingPeriods;
private final Duration waitingPeriod;
private final Clock clock;
public ChangeNumberWaitingPeriodManager(final FaultTolerantRedisClusterClient redisCluster, final Duration waitingPeriod) {
this.redisCluster = redisCluster;
public ChangeNumberWaitingPeriodManager(final ChangeNumberWaitingPeriods changeNumberWaitingPeriods,
final Duration waitingPeriod, final Clock clock) {
this.changeNumberWaitingPeriods = changeNumberWaitingPeriods;
this.waitingPeriod = waitingPeriod;
this.clock = clock;
}
/// Must be called when an account is created, including re-registration
@VisibleForTesting
public CompletableFuture<Void> handleAccountCreated(final UUID aci, final Instant created) {
return redisCluster.withCluster(conn -> conn.async().set(key(aci), "", SetArgs.Builder.exAt(created.plus(waitingPeriod))))
.toCompletableFuture()
.thenApply(_ -> null);
return changeNumberWaitingPeriods.setExpiration(aci, created.plus(waitingPeriod));
}
/// Returns the waiting period duration remaining, if any. If present, {@code duration} will always be positive.
Optional<Duration> getWaitingPeriodRemaining(final UUID aci) {
final long ttlMillis = redisCluster.withCluster(conn -> conn.sync().ttl(key(aci)));
if (ttlMillis == -1) {
// key present without TTL. This should never happen.
LOGGER.error("No expiration for {}", aci);
throw new RuntimeException("No expiration for key that must always have a expiration");
}
if (ttlMillis == -2) {
// key did not exist
return Optional.empty();
}
final Duration remaining = Duration.ofMillis(ttlMillis);
return remaining.isPositive() ? Optional.of(remaining) : Optional.empty();
}
@VisibleForTesting
static String key(final UUID aci) {
return "changeNumberWaiting::{" + aci + "}";
return changeNumberWaitingPeriods.getExpiration(aci)
.flatMap(expiration -> {
final Duration remaining = Duration.between(clock.instant(), expiration);
return remaining.isPositive() ? Optional.of(remaining) : Optional.empty();
});
}
}

View File

@ -0,0 +1,75 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import com.google.common.annotations.VisibleForTesting;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import org.whispersystems.textsecuregcm.util.Util;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
public class ChangeNumberWaitingPeriods {
// hash key; bytes
static final String KEY_ACCOUNT_UUID = "U";
// expiration timestamp in epoch seconds; number
static final String ATTR_TTL = "E";
private final String tableName;
private final DynamoDbAsyncClient dynamoDbAsyncClient;
private final DynamoDbClient dynamoDbClient;
public ChangeNumberWaitingPeriods(final String tableName, final DynamoDbAsyncClient dynamoDbAsyncClient, final DynamoDbClient dynamoDbClient) {
this.tableName = tableName;
this.dynamoDbAsyncClient = dynamoDbAsyncClient;
this.dynamoDbClient = dynamoDbClient;
}
public CompletableFuture<Void> setExpiration(final UUID aci, final Instant expiration) {
return dynamoDbAsyncClient.putItem(PutItemRequest.builder()
.tableName(tableName)
.item(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(aci),
ATTR_TTL, AttributeValues.fromLong(expiration.getEpochSecond())))
.build())
.thenRun(Util.NOOP);
}
public Optional<Instant> getExpiration(final UUID aci) {
final GetItemResponse response = dynamoDbClient.getItem(GetItemRequest.builder()
.tableName(tableName)
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(aci)))
.consistentRead(true)
.build());
if (!response.hasItem()) {
return Optional.empty();
}
return AttributeValues.get(response.item(), ATTR_TTL)
.map(AttributeValue::n)
.map(Long::parseLong)
.map(Instant::ofEpochSecond)
.filter(instant -> instant.isAfter(Instant.now()));
}
@VisibleForTesting
public void delete(final UUID aci) {
dynamoDbClient.deleteItem(DeleteItemRequest.builder()
.tableName(tableName)
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(aci)))
.build());
}
}

View File

@ -53,6 +53,7 @@ import org.whispersystems.textsecuregcm.storage.AccountLockManager;
import org.whispersystems.textsecuregcm.storage.Accounts;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberWaitingPeriodManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberWaitingPeriods;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.DynamoDbRecoveryManager;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
@ -282,8 +283,10 @@ public record CommandDependencies(
configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());
RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager =
new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords);
final ChangeNumberWaitingPeriods changeNumberWaitingPeriods = new ChangeNumberWaitingPeriods(
configuration.getDynamoDbTables().getChangeNumberWaitingPeriods().getTableName(), dynamoDbAsyncClient, dynamoDbClient);
final ChangeNumberWaitingPeriodManager changeNumberWaitingPeriodManager = new ChangeNumberWaitingPeriodManager(
rateLimitersCluster, configuration.getChangeNumber().postRegistrationWaitingPeriod());
changeNumberWaitingPeriods, configuration.getChangeNumber().postRegistrationWaitingPeriod(), clock);
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
pubsubClient, accountLockManager, keys, messagesManager, profilesManager,
changeNumberWaitingPeriodManager, secureStorageClient, secureValueRecovery2Client, disconnectionRequestManager,

View File

@ -5,9 +5,9 @@
package org.whispersystems.textsecuregcm.storage;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
@ -15,12 +15,13 @@ import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;
class ChangeNumberWaitingPeriodManagerTest {
@RegisterExtension
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
static final DynamoDbExtension DYNAMO_DB_EXTENSION =
new DynamoDbExtension(Tables.CHANGE_NUMBER_WAITING_PERIODS);
private static final Duration WAITING_PERIOD = Duration.ofDays(7);
@ -29,7 +30,11 @@ class ChangeNumberWaitingPeriodManagerTest {
@BeforeEach
void setUp() {
changeNumberWaitingPeriodManager = new ChangeNumberWaitingPeriodManager(
REDIS_CLUSTER_EXTENSION.getRedisCluster(), WAITING_PERIOD);
new ChangeNumberWaitingPeriods(Tables.CHANGE_NUMBER_WAITING_PERIODS.tableName(),
DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),
DYNAMO_DB_EXTENSION.getDynamoDbClient()),
WAITING_PERIOD,
Clock.systemUTC());
}
@Test
@ -54,17 +59,4 @@ class ChangeNumberWaitingPeriodManagerTest {
assertTrue(changeNumberWaitingPeriodManager.getWaitingPeriodRemaining(aci).isEmpty());
}
@Test
void testNoTtlException() throws Exception {
final UUID aci = UUID.randomUUID();
changeNumberWaitingPeriodManager.handleAccountCreated(aci, Instant.now()).get(5, TimeUnit.SECONDS);
REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(conn ->
conn.sync().persist(ChangeNumberWaitingPeriodManager.key(aci)));
assertThrows(RuntimeException.class, () -> changeNumberWaitingPeriodManager.getWaitingPeriodRemaining(aci),
"This is an impossible scenario, and it should throw an exception");
}
}

View File

@ -0,0 +1,96 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;
class ChangeNumberWaitingPeriodsTest {
@RegisterExtension
static final DynamoDbExtension DYNAMO_DB_EXTENSION =
new DynamoDbExtension(Tables.CHANGE_NUMBER_WAITING_PERIODS);
private ChangeNumberWaitingPeriods changeNumberWaitingPeriods;
@BeforeEach
void setUp() {
changeNumberWaitingPeriods = new ChangeNumberWaitingPeriods(
Tables.CHANGE_NUMBER_WAITING_PERIODS.tableName(),
DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),
DYNAMO_DB_EXTENSION.getDynamoDbClient());
}
@Test
void getExpiration_unknownAci() throws Exception {
assertTrue(changeNumberWaitingPeriods.getExpiration(UUID.randomUUID()).isEmpty());
}
@Test
void setAndGetExpiration() throws Exception {
final UUID aci = UUID.randomUUID();
// truncate to seconds because the TTL attribute stores epoch seconds
final Instant expiration = Instant.now().plusSeconds(3600).truncatedTo(ChronoUnit.SECONDS);
changeNumberWaitingPeriods.setExpiration(aci, expiration).get();
final Optional<Instant> result = changeNumberWaitingPeriods.getExpiration(aci);
assertTrue(result.isPresent());
assertEquals(expiration, result.get());
assertTrue(changeNumberWaitingPeriods.getExpiration(UUID.randomUUID()).isEmpty(), "new UUID has no entry");
}
@Test
void setExpiration_overwritesExistingEntry() throws Exception {
final UUID aci = UUID.randomUUID();
final Instant first = Instant.now().plusSeconds(3600).truncatedTo(ChronoUnit.SECONDS);
final Instant second = Instant.now().plusSeconds(7200).truncatedTo(ChronoUnit.SECONDS);
changeNumberWaitingPeriods.setExpiration(aci, first).get();
{
final Optional<Instant> result = changeNumberWaitingPeriods.getExpiration(aci);
assertTrue(result.isPresent());
assertEquals(first, result.get());
}
changeNumberWaitingPeriods.setExpiration(aci, second).get();
{
final Optional<Instant> result = changeNumberWaitingPeriods.getExpiration(aci);
assertTrue(result.isPresent());
assertEquals(second, result.get());
}
}
@Test
void delete_removesExisting() throws Exception {
final UUID aci = UUID.randomUUID();
changeNumberWaitingPeriods.setExpiration(aci, Instant.now().plusSeconds(3600)).get();
assertTrue( changeNumberWaitingPeriods.getExpiration(aci).isPresent());
changeNumberWaitingPeriods.delete(aci);
assertTrue(changeNumberWaitingPeriods.getExpiration(aci).isEmpty());
}
@Test
void delete_missingIsNoop() {
assertDoesNotThrow(() -> changeNumberWaitingPeriods.delete(UUID.randomUUID()));
}
}

View File

@ -60,6 +60,15 @@ public final class DynamoDbExtensionSchema {
.attributeType(ScalarAttributeType.B).build()),
Collections.emptyList(), Collections.emptyList()),
CHANGE_NUMBER_WAITING_PERIODS("change_number_waiting_periods_test",
ChangeNumberWaitingPeriods.KEY_ACCOUNT_UUID,
null,
List.of(AttributeDefinition.builder()
.attributeName(ChangeNumberWaitingPeriods.KEY_ACCOUNT_UUID)
.attributeType(ScalarAttributeType.B)
.build()),
List.of(), List.of()),
CLIENT_RELEASES("client_releases_test",
ClientReleases.ATTR_PLATFORM,
ClientReleases.ATTR_VERSION,

View File

@ -98,6 +98,8 @@ dynamoDbTables:
tableName: apple_device_check_public_keys_test
backups:
tableName: backups_test
changeNumberWaitingPeriods:
tableName: change_number_waiting_periods_test
clientReleases:
tableName: client_releases_test
deletedAccounts: