diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 6604fbfcb..75f65f414 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -171,6 +171,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private ZkConfig zkConfig; + @Valid + @NotNull + @JsonProperty + private RemoteConfigConfiguration remoteConfig; + private Map transparentDataIndex = new HashMap<>(); public RecaptchaConfiguration getRecaptchaConfiguration() { @@ -299,4 +304,7 @@ public class WhisperServerConfiguration extends Configuration { return zkConfig; } + public RemoteConfigConfiguration getRemoteConfigConfiguration() { + return remoteConfig; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index e7d6397b4..05e3ca20d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -179,6 +179,7 @@ public class WhisperServerService extends Application accountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(accountAuthenticator).buildAuthFilter (); AuthFilter disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(disabledPermittedAccountAuthenticator).buildAuthFilter(); @@ -285,6 +289,7 @@ public class WhisperServerService extends Application authorizedTokens = new LinkedList<>(); + + public List getAuthorizedTokens() { + return authorizedTokens; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java new file mode 100644 index 000000000..f6e11ca8e --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java @@ -0,0 +1,99 @@ +package org.whispersystems.textsecuregcm.controllers; + +import com.codahale.metrics.annotation.Timed; +import com.google.common.annotations.VisibleForTesting; +import org.whispersystems.textsecuregcm.entities.UserRemoteConfig; +import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.RemoteConfig; +import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager; +import org.whispersystems.textsecuregcm.util.Conversions; + +import javax.validation.Valid; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.stream.Collectors; + +import io.dropwizard.auth.Auth; + +@Path("/v1/config") +public class RemoteConfigController { + + private final RemoteConfigsManager remoteConfigsManager; + private final List configAuthTokens; + + public RemoteConfigController(RemoteConfigsManager remoteConfigsManager, List configAuthTokens) { + this.remoteConfigsManager = remoteConfigsManager; + this.configAuthTokens = configAuthTokens; + } + + @Timed + @GET + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public UserRemoteConfigList getAll(@Auth Account account) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA1"); + byte[] number = account.getNumber().getBytes(); + + return new UserRemoteConfigList(remoteConfigsManager.getAll().stream().map(config -> new UserRemoteConfig(config.getName(), + isInBucket(digest, number, + config.getName().getBytes(), + config.getPercentage()))) + .collect(Collectors.toList())); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + @Timed + @PUT + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public void set(@HeaderParam("Config-Token") String configToken, @Valid RemoteConfig config) { + if (!isAuthorized(configToken)) { + throw new WebApplicationException(Response.Status.UNAUTHORIZED); + } + + remoteConfigsManager.set(config); + } + + @Timed + @DELETE + @Path("/{name}") + public void delete(@HeaderParam("Config-Token") String configToken, @PathParam("name") String name) { + if (!isAuthorized(configToken)) { + throw new WebApplicationException(Response.Status.UNAUTHORIZED); + } + + remoteConfigsManager.delete(name); + } + + @VisibleForTesting + public static boolean isInBucket(MessageDigest digest, byte[] user, byte[] configName, int configPercentage) { + digest.update(user); + + byte[] hash = digest.digest(configName); + int bucket = (int)(Math.abs(Conversions.byteArrayToLong(hash)) % 100); + + return bucket < configPercentage; + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private boolean isAuthorized(String configToken) { + return configAuthTokens.stream().anyMatch(authorized -> MessageDigest.isEqual(authorized.getBytes(), configToken.getBytes())); + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfig.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfig.java new file mode 100644 index 000000000..f8e01fa60 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfig.java @@ -0,0 +1,27 @@ +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class UserRemoteConfig { + + @JsonProperty + private String name; + + @JsonProperty + private boolean enabled; + + public UserRemoteConfig() {} + + public UserRemoteConfig(String name, boolean enabled) { + this.name = name; + this.enabled = enabled; + } + + public String getName() { + return name; + } + + public boolean isEnabled() { + return enabled; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfigList.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfigList.java new file mode 100644 index 000000000..d13757029 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfigList.java @@ -0,0 +1,21 @@ +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class UserRemoteConfigList { + + @JsonProperty + private List config; + + public UserRemoteConfigList() {} + + public UserRemoteConfigList(List config) { + this.config = config; + } + + public List getConfig() { + return config; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfig.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfig.java new file mode 100644 index 000000000..f1e9d8065 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfig.java @@ -0,0 +1,36 @@ +package org.whispersystems.textsecuregcm.storage; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; + +public class RemoteConfig { + + @JsonProperty + @Pattern(regexp = "[A-Za-z0-9\\.]+") + private String name; + + @JsonProperty + @NotNull + @Min(0) + @Max(100) + private int percentage; + + public RemoteConfig() {} + + public RemoteConfig(String name, int percentage) { + this.name = name; + this.percentage = percentage; + } + + public int getPercentage() { + return percentage; + } + + public String getName() { + return name; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigs.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigs.java new file mode 100644 index 000000000..792d96cf3 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigs.java @@ -0,0 +1,70 @@ +package org.whispersystems.textsecuregcm.storage; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.SharedMetricRegistries; +import com.codahale.metrics.Timer; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jdbi.v3.core.transaction.TransactionIsolationLevel; +import org.whispersystems.textsecuregcm.storage.mappers.AccountRowMapper; +import org.whispersystems.textsecuregcm.storage.mappers.RemoteConfigRowMapper; +import org.whispersystems.textsecuregcm.util.Constants; +import org.whispersystems.textsecuregcm.util.SystemMapper; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static com.codahale.metrics.MetricRegistry.name; + +public class RemoteConfigs { + + public static final String ID = "id"; + public static final String NAME = "name"; + public static final String PERCENTAGE = "percentage"; + + private static final ObjectMapper mapper = SystemMapper.getMapper(); + + private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); + private final Timer setTimer = metricRegistry.timer(name(Accounts.class, "set" )); + private final Timer getAllTimer = metricRegistry.timer(name(Accounts.class, "getAll")); + private final Timer deleteTimer = metricRegistry.timer(name(Accounts.class, "delete")); + + private final FaultTolerantDatabase database; + + public RemoteConfigs(FaultTolerantDatabase database) { + this.database = database; + this.database.getDatabase().registerRowMapper(new RemoteConfigRowMapper()); + } + + public void set(RemoteConfig remoteConfig) { + database.use(jdbi -> jdbi.useHandle(handle -> { + try (Timer.Context ignored = setTimer.time()) { + handle.createUpdate("INSERT INTO remote_config (" + NAME + ", " + PERCENTAGE + ") VALUES (:name, :percentage) ON CONFLICT(" + NAME + ") DO UPDATE SET " + PERCENTAGE + " = EXCLUDED." + PERCENTAGE) + .bind("name", remoteConfig.getName()) + .bind("percentage", remoteConfig.getPercentage()) + .execute(); + } + })); + } + + public List getAll() { + return database.with(jdbi -> jdbi.withHandle(handle -> { + try (Timer.Context ignored = getAllTimer.time()) { + return handle.createQuery("SELECT * FROM remote_config") + .mapTo(RemoteConfig.class) + .list(); + } + })); + } + + public void delete(String name) { + database.use(jdbi -> jdbi.useHandle(handle -> { + try (Timer.Context ignored = deleteTimer.time()) { + handle.createUpdate("DELETE FROM remote_config WHERE " + NAME + " = :name") + .bind("name", name) + .execute(); + } + })); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsManager.java new file mode 100644 index 000000000..4a775302c --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsManager.java @@ -0,0 +1,67 @@ +package org.whispersystems.textsecuregcm.storage; + +import com.google.common.annotations.VisibleForTesting; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.util.Util; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import io.dropwizard.lifecycle.Managed; + +public class RemoteConfigsManager implements Managed { + + private final Logger logger = LoggerFactory.getLogger(RemoteConfigsManager.class); + + private final RemoteConfigs remoteConfigs; + private final long sleepInterval; + + private AtomicReference> cachedConfigs = new AtomicReference<>(new LinkedList<>()); + + public RemoteConfigsManager(RemoteConfigs remoteConfigs) { + this(remoteConfigs, TimeUnit.SECONDS.toMillis(10)); + } + + @VisibleForTesting + public RemoteConfigsManager(RemoteConfigs remoteConfigs, long sleepInterval) { + this.remoteConfigs = remoteConfigs; + this.sleepInterval = sleepInterval; + } + + @Override + public void start() { + this.cachedConfigs.set(remoteConfigs.getAll()); + + new Thread(() -> { + while (true) { + try { + this.cachedConfigs.set(remoteConfigs.getAll()); + } catch (Throwable t) { + logger.warn("Error updating remote configs cache", t); + } + + Util.sleep(sleepInterval); + } + }).start(); + } + + public List getAll() { + return cachedConfigs.get(); + } + + public void set(RemoteConfig config) { + remoteConfigs.set(config); + } + + public void delete(String name) { + remoteConfigs.delete(name); + } + + @Override + public void stop() throws Exception { + + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/mappers/RemoteConfigRowMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/mappers/RemoteConfigRowMapper.java new file mode 100644 index 000000000..817f05239 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/mappers/RemoteConfigRowMapper.java @@ -0,0 +1,18 @@ +package org.whispersystems.textsecuregcm.storage.mappers; + +import org.jdbi.v3.core.mapper.RowMapper; +import org.jdbi.v3.core.statement.StatementContext; +import org.whispersystems.textsecuregcm.storage.RemoteConfig; +import org.whispersystems.textsecuregcm.storage.RemoteConfigs; + +import java.sql.ResultSet; +import java.sql.SQLException; + + +public class RemoteConfigRowMapper implements RowMapper { + + @Override + public RemoteConfig map(ResultSet rs, StatementContext ctx) throws SQLException { + return new RemoteConfig(rs.getString(RemoteConfigs.NAME), rs.getInt(RemoteConfigs.PERCENTAGE)); + } +} diff --git a/service/src/main/resources/accountsdb.xml b/service/src/main/resources/accountsdb.xml index 76d6ef050..75a1a0e6b 100644 --- a/service/src/main/resources/accountsdb.xml +++ b/service/src/main/resources/accountsdb.xml @@ -273,4 +273,24 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java new file mode 100644 index 000000000..c33a62a70 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java @@ -0,0 +1,207 @@ +package org.whispersystems.textsecuregcm.tests.controllers; + +import com.google.common.collect.ImmutableSet; +import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccount; +import org.whispersystems.textsecuregcm.controllers.RemoteConfigController; +import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList; +import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.RemoteConfig; +import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; + +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; +import io.dropwizard.testing.junit.ResourceTestRule; +import static org.assertj.core.api.Java6Assertions.assertThat; +import static org.mockito.Mockito.*; + +public class RemoteConfigControllerTest { + + private RemoteConfigsManager remoteConfigsManager = mock(RemoteConfigsManager.class); + private List remoteConfigsAuth = new LinkedList<>() {{ + add("foo"); + add("bar"); + }}; + + @Rule + public final ResourceTestRule resources = ResourceTestRule.builder() + .addProvider(AuthHelper.getAuthFilter()) + .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(ImmutableSet.of(Account.class, DisabledPermittedAccount.class))) + .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) + .addProvider(new DeviceLimitExceededExceptionMapper()) + .addResource(new RemoteConfigController(remoteConfigsManager, remoteConfigsAuth)) + .build(); + + + @Before + public void setup() throws Exception { + when(remoteConfigsManager.getAll()).thenReturn(new LinkedList<>() {{ + add(new RemoteConfig("android.stickers", 25)); + add(new RemoteConfig("ios.stickers", 50)); + add(new RemoteConfig("always.true", 100)); + }}); + } + + @Test + public void testRetrieveConfig() { + UserRemoteConfigList configuration = resources.getJerseyTest() + .target("/v1/config/") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) + .get(UserRemoteConfigList.class); + + verify(remoteConfigsManager, times(1)).getAll(); + + assertThat(configuration.getConfig().size()).isEqualTo(3); + assertThat(configuration.getConfig().get(0).getName()).isEqualTo("android.stickers"); + assertThat(configuration.getConfig().get(1).getName()).isEqualTo("ios.stickers"); + assertThat(configuration.getConfig().get(2).getName()).isEqualTo("always.true"); + assertThat(configuration.getConfig().get(2).isEnabled()).isEqualTo(true); + } + + @Test + public void testRetrieveConfigUnauthorized() { + Response response = resources.getJerseyTest() + .target("/v1/config/") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.INVALID_PASSWORD)) + .get(); + + assertThat(response.getStatus()).isEqualTo(401); + + verifyNoMoreInteractions(remoteConfigsManager); + } + + + @Test + public void testSetConfig() { + Response response = resources.getJerseyTest() + .target("/v1/config") + .request() + .header("Config-Token", "foo") + .put(Entity.entity(new RemoteConfig("android.stickers", 88), MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(204); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RemoteConfig.class); + + verify(remoteConfigsManager, times(1)).set(captor.capture()); + + assertThat(captor.getValue().getName()).isEqualTo("android.stickers"); + assertThat(captor.getValue().getPercentage()).isEqualTo(88); + } + + @Test + public void testSetConfigUnauthorized() { + Response response = resources.getJerseyTest() + .target("/v1/config") + .request() + .header("Config-Token", "baz") + .put(Entity.entity(new RemoteConfig("android.stickers", 88), MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(401); + + verifyNoMoreInteractions(remoteConfigsManager); + } + + @Test + public void testSetConfigBadName() { + Response response = resources.getJerseyTest() + .target("/v1/config") + .request() + .header("Config-Token", "foo") + .put(Entity.entity(new RemoteConfig("android-stickers", 88), MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(422); + + verifyNoMoreInteractions(remoteConfigsManager); + } + + @Test + public void testSetConfigEmptyName() { + Response response = resources.getJerseyTest() + .target("/v1/config") + .request() + .header("Config-Token", "foo") + .put(Entity.entity(new RemoteConfig("", 88), MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(422); + + verifyNoMoreInteractions(remoteConfigsManager); + } + + + @Test + public void testDelete() { + Response response = resources.getJerseyTest() + .target("/v1/config/android.stickers") + .request() + .header("Config-Token", "foo") + .delete(); + + assertThat(response.getStatus()).isEqualTo(204); + + verify(remoteConfigsManager, times(1)).delete("android.stickers"); + verifyNoMoreInteractions(remoteConfigsManager); + } + + @Test + public void testDeleteUnauthorized() { + Response response = resources.getJerseyTest() + .target("/v1/config/android.stickers") + .request() + .header("Config-Token", "baz") + .delete(); + + assertThat(response.getStatus()).isEqualTo(401); + + verifyNoMoreInteractions(remoteConfigsManager); + } + + @Test + public void testMath() throws NoSuchAlgorithmException { + List remoteConfigList = remoteConfigsManager.getAll(); + Map enabledMap = new HashMap<>(); + MessageDigest digest = MessageDigest.getInstance("SHA1"); + int iterations = 100000; + + for (int i=0;i results = remoteConfigs.getAll(); + + assertThat(results.size()).isEqualTo(2); + assertThat(results.get(0).getName()).isEqualTo("android.stickers"); + assertThat(results.get(0).getPercentage()).isEqualTo(50); + assertThat(results.get(1).getName()).isEqualTo("ios.stickers"); + assertThat(results.get(1).getPercentage()).isEqualTo(75); + + } + +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RemoteConfigsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RemoteConfigsTest.java new file mode 100644 index 000000000..d1cb4696a --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RemoteConfigsTest.java @@ -0,0 +1,73 @@ +package org.whispersystems.textsecuregcm.tests.storage; + +import com.opentable.db.postgres.embedded.LiquibasePreparer; +import com.opentable.db.postgres.junit.EmbeddedPostgresRules; +import com.opentable.db.postgres.junit.PreparedDbRule; +import org.jdbi.v3.core.Jdbi; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; +import org.whispersystems.textsecuregcm.storage.FaultTolerantDatabase; +import org.whispersystems.textsecuregcm.storage.RemoteConfig; +import org.whispersystems.textsecuregcm.storage.RemoteConfigs; + +import java.sql.SQLException; +import java.util.List; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class RemoteConfigsTest { + + @Rule + public PreparedDbRule db = EmbeddedPostgresRules.preparedDatabase(LiquibasePreparer.forClasspathLocation("accountsdb.xml")); + + private RemoteConfigs remoteConfigs; + + @Before + public void setup() { + this.remoteConfigs = new RemoteConfigs(new FaultTolerantDatabase("remote_configs-test", Jdbi.create(db.getTestDatabase()), new CircuitBreakerConfiguration())); + } + + @Test + public void testStore() throws SQLException { + remoteConfigs.set(new RemoteConfig("android.stickers", 50)); + + List configs = remoteConfigs.getAll(); + + assertThat(configs.size()).isEqualTo(1); + assertThat(configs.get(0).getName()).isEqualTo("android.stickers"); + assertThat(configs.get(0).getPercentage()).isEqualTo(50); + } + + @Test + public void testUpdate() throws SQLException { + remoteConfigs.set(new RemoteConfig("android.stickers", 50)); + remoteConfigs.set(new RemoteConfig("ios.stickers", 50)); + remoteConfigs.set(new RemoteConfig("ios.stickers", 75)); + + List configs = remoteConfigs.getAll(); + + assertThat(configs.size()).isEqualTo(2); + assertThat(configs.get(0).getName()).isEqualTo("android.stickers"); + assertThat(configs.get(0).getPercentage()).isEqualTo(50); + assertThat(configs.get(1).getName()).isEqualTo("ios.stickers"); + assertThat(configs.get(1).getPercentage()).isEqualTo(75); + } + + @Test + public void testDelete() { + remoteConfigs.set(new RemoteConfig("android.stickers", 50)); + remoteConfigs.set(new RemoteConfig("ios.stickers", 50)); + remoteConfigs.set(new RemoteConfig("ios.stickers", 75)); + remoteConfigs.delete("android.stickers"); + + List configs = remoteConfigs.getAll(); + + assertThat(configs.size()).isEqualTo(1); + assertThat(configs.get(0).getName()).isEqualTo("ios.stickers"); + assertThat(configs.get(0).getPercentage()).isEqualTo(75); + } + + +}