From 1ef3546822de38b64693261c6af5df2077859361 Mon Sep 17 00:00:00 2001 From: Jon Chambers <63609320+jon-signal@users.noreply.github.com> Date: Wed, 26 Aug 2020 20:27:33 -0400 Subject: [PATCH] Add support for server-side feature flags --- service/config/sample.yml | 7 ++ .../WhisperServerConfiguration.java | 10 ++ .../textsecuregcm/WhisperServerService.java | 10 +- .../FeatureFlagConfiguration.java | 17 +++ .../controllers/FeatureFlagsController.java | 72 +++++++++++ .../textsecuregcm/storage/FeatureFlags.java | 77 ++++++++++++ .../storage/FeatureFlagsManager.java | 82 +++++++++++++ .../textsecuregcm/workers/VacuumCommand.java | 5 + service/src/main/resources/accountsdb.xml | 10 ++ .../FeatureFlagsControllerTest.java | 116 ++++++++++++++++++ .../storage/FeatureFlagsManagerTest.java | 44 +++++++ .../storage/FeatureFlagsTest.java | 63 ++++++++++ 12 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/configuration/FeatureFlagConfiguration.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/controllers/FeatureFlagsController.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/FeatureFlags.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/FeatureFlagsManager.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/controllers/FeatureFlagsControllerTest.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/storage/FeatureFlagsManagerTest.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/storage/FeatureFlagsTest.java diff --git a/service/config/sample.yml b/service/config/sample.yml index 2aacea159..d6cc422ee 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -135,3 +135,10 @@ remoteConfig: - # ... - # Nth authorized token globalConfig: # keys and values that are given to clients on GET /v1/config + +featureFlag: + authorizedTokens: + - # 1st authorized token + - # 2nd authorized token + - # ... + - # Nth authorized token diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index e35127c45..24c2d59c0 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -25,6 +25,7 @@ import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguratio import org.whispersystems.textsecuregcm.configuration.CdnConfiguration; import org.whispersystems.textsecuregcm.configuration.DatabaseConfiguration; import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration; +import org.whispersystems.textsecuregcm.configuration.FeatureFlagConfiguration; import org.whispersystems.textsecuregcm.configuration.GcmConfiguration; import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration; import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration; @@ -211,6 +212,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private RemoteConfigConfiguration remoteConfig; + @Valid + @NotNull + @JsonProperty + private FeatureFlagConfiguration featureFlag; + private Map transparentDataIndex = new HashMap<>(); public RecaptchaConfiguration getRecaptchaConfiguration() { @@ -354,4 +360,8 @@ public class WhisperServerConfiguration extends Configuration { public RemoteConfigConfiguration getRemoteConfigConfiguration() { return remoteConfig; } + + public FeatureFlagConfiguration getFeatureFlagConfiguration() { + return featureFlag; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 3fb675c95..be7c3db86 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -58,7 +58,6 @@ import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccount; import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccountAuthenticator; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator; import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; -import org.whispersystems.textsecuregcm.configuration.MicrometerConfiguration; import org.whispersystems.textsecuregcm.controllers.AccountController; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV1; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2; @@ -66,6 +65,7 @@ import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3; import org.whispersystems.textsecuregcm.controllers.CertificateController; import org.whispersystems.textsecuregcm.controllers.DeviceController; import org.whispersystems.textsecuregcm.controllers.DirectoryController; +import org.whispersystems.textsecuregcm.controllers.FeatureFlagsController; import org.whispersystems.textsecuregcm.controllers.KeepAliveController; import org.whispersystems.textsecuregcm.controllers.KeysController; import org.whispersystems.textsecuregcm.controllers.MessageController; @@ -122,6 +122,8 @@ import org.whispersystems.textsecuregcm.storage.DirectoryManager; import org.whispersystems.textsecuregcm.storage.DirectoryReconciler; import org.whispersystems.textsecuregcm.storage.DirectoryReconciliationClient; import org.whispersystems.textsecuregcm.storage.FaultTolerantDatabase; +import org.whispersystems.textsecuregcm.storage.FeatureFlags; +import org.whispersystems.textsecuregcm.storage.FeatureFlagsManager; import org.whispersystems.textsecuregcm.storage.Keys; import org.whispersystems.textsecuregcm.storage.MessagePersister; import org.whispersystems.textsecuregcm.storage.Messages; @@ -163,7 +165,6 @@ import java.security.Security; import java.time.Duration; import java.util.EnumSet; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ExecutorService; @@ -268,6 +269,7 @@ public class WhisperServerService extends Application(1_000)).build(); ExecutorService messageCacheClusterExperimentExecutor = environment.lifecycle().executorService("messages_cache_experiment").maxThreads(8).workQueue(new ArrayBlockingQueue<>(1_000)).build(); ExecutorService websocketExperimentExecutor = environment.lifecycle().executorService("websocketPresenceExperiment").maxThreads(8).workQueue(new ArrayBlockingQueue<>(1_000)).build(); @@ -301,6 +304,7 @@ public class WhisperServerService extends Application accountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(accountAuthenticator).buildAuthFilter (); AuthFilter disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(disabledPermittedAccountAuthenticator).buildAuthFilter(); @@ -400,6 +405,7 @@ public class WhisperServerService extends Application webSocketEnvironment = new WebSocketEnvironment<>(environment, config.getWebSocketConfiguration(), 90000); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/FeatureFlagConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/FeatureFlagConfiguration.java new file mode 100644 index 000000000..41074cdae --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/FeatureFlagConfiguration.java @@ -0,0 +1,17 @@ +package org.whispersystems.textsecuregcm.configuration; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import javax.validation.constraints.NotNull; +import java.util.LinkedList; +import java.util.List; + +public class FeatureFlagConfiguration { + @JsonProperty + @NotNull + private List authorizedTokens = new LinkedList<>(); + + public List getAuthorizedTokens() { + return authorizedTokens; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/FeatureFlagsController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/FeatureFlagsController.java new file mode 100644 index 000000000..731629a30 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/FeatureFlagsController.java @@ -0,0 +1,72 @@ +package org.whispersystems.textsecuregcm.controllers; + +import com.codahale.metrics.annotation.Timed; +import com.google.common.annotations.VisibleForTesting; +import org.whispersystems.textsecuregcm.storage.FeatureFlagsManager; + +import javax.ws.rs.DELETE; +import javax.ws.rs.FormParam; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; +import java.util.stream.Collectors; + +@Path("/v1/featureflag") +public class FeatureFlagsController { + + private final FeatureFlagsManager featureFlagsManager; + private final List authorizedTokens; + + public FeatureFlagsController(final FeatureFlagsManager featureFlagsManager, final List authorizedTokens) { + this.featureFlagsManager = featureFlagsManager; + this.authorizedTokens = authorizedTokens.stream().map(token -> token.getBytes(StandardCharsets.UTF_8)).collect(Collectors.toList()); + } + + @Timed + @PUT + @Path("/{featureFlag}") + public void set(@HeaderParam("Token") final String token, @PathParam("featureFlag") final String featureFlag, @FormParam("active") final boolean active) { + if (!isAuthorized(token)) { + throw new WebApplicationException(Response.Status.UNAUTHORIZED); + } + + featureFlagsManager.setFeatureFlag(featureFlag, active); + } + + @Timed + @DELETE + @Path("/{featureFlag}") + public void delete(@HeaderParam("Token") final String token, @PathParam("featureFlag") final String featureFlag) { + if (!isAuthorized(token)) { + throw new WebApplicationException(Response.Status.UNAUTHORIZED); + } + + featureFlagsManager.deleteFeatureFlag(featureFlag); + } + + @VisibleForTesting + boolean isAuthorized(final String token) { + if (token == null) { + return false; + } + + final byte[] tokenBytes = token.getBytes(StandardCharsets.UTF_8); + + boolean authorized = false; + + for (final byte[] authorizedToken : authorizedTokens) { + //noinspection IfStatementMissingBreakInLoop + if (MessageDigest.isEqual(authorizedToken, tokenBytes)) { + authorized = true; + } + } + + return authorized; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/FeatureFlags.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/FeatureFlags.java new file mode 100644 index 000000000..532d33103 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/FeatureFlags.java @@ -0,0 +1,77 @@ +package org.whispersystems.textsecuregcm.storage; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.SharedMetricRegistries; +import com.codahale.metrics.Timer; +import org.jdbi.v3.core.mapper.RowMapper; +import org.whispersystems.textsecuregcm.util.Constants; +import org.whispersystems.textsecuregcm.util.Pair; + +import java.util.Map; +import java.util.stream.Collectors; + +import static com.codahale.metrics.MetricRegistry.name; + +/** + * The feature flag database is a persistent store of the state of all server-side feature flags. Feature flags are + * identified by a human-readable name (e.g. "invert-nano-flappers") and are either active or inactive. + *

+ * The feature flag database provides the most up-to-date possible view of feature flags, but does so at the cost of + * interacting with a remote data store. In nearly all cases, callers should prefer a cached, eventually-consistent + * view of feature flags (see {@link FeatureFlagsManager}). + *

+ * When an operation requiring a feature flag has finished, callers should delete the feature flag to prevent + * accumulation of non-functional flags. + */ +public class FeatureFlags { + + private final FaultTolerantDatabase database; + + private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); + private final Timer getAllTimer = metricRegistry.timer(name(getClass(), "getAll")); + private final Timer updateTimer = metricRegistry.timer(name(getClass(), "update")); + private final Timer deleteTimer = metricRegistry.timer(name(getClass(), "delete")); + private final Timer vacuumTimer = metricRegistry.timer(name(getClass(), "vacuum")); + + private static final RowMapper> PAIR_ROW_MAPPER = (resultSet, statementContext) -> + new Pair<>(resultSet.getString("flag"), resultSet.getBoolean("active")); + + public FeatureFlags(final FaultTolerantDatabase database) { + this.database = database; + } + + public Map getFeatureFlags() { + try (final Timer.Context ignored = getAllTimer.time()) { + return database.with(jdbi -> jdbi.withHandle(handle -> handle.createQuery("SELECT flag, active FROM feature_flags") + .map(PAIR_ROW_MAPPER) + .list() + .stream() + .collect(Collectors.toMap(Pair::first, Pair::second)))); + } + } + + public void setFlag(final String featureFlag, final boolean active) { + try (final Timer.Context ignored = updateTimer.time()) { + database.use(jdbi -> jdbi.withHandle(handle -> handle.createUpdate("INSERT INTO feature_flags (flag, active) VALUES (:featureFlag, :active) ON CONFLICT (flag) DO UPDATE SET active = EXCLUDED.active") + .bind("featureFlag", featureFlag) + .bind("active", active) + .execute())); + } + } + + public void deleteFlag(final String featureFlag) { + try (final Timer.Context ignored = deleteTimer.time()) { + database.use(jdbi -> jdbi.withHandle(handle -> handle.createUpdate("DELETE FROM feature_flags WHERE flag = :featureFlag") + .bind("featureFlag", featureFlag) + .execute())); + } + } + + public void vacuum() { + try (final Timer.Context ignored = vacuumTimer.time()) { + database.use(jdbi -> jdbi.useHandle(handle -> { + handle.execute("VACUUM feature_flags"); + })); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/FeatureFlagsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/FeatureFlagsManager.java new file mode 100644 index 000000000..554ef583a --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/FeatureFlagsManager.java @@ -0,0 +1,82 @@ +package org.whispersystems.textsecuregcm.storage; + +import com.google.common.annotations.VisibleForTesting; +import io.dropwizard.lifecycle.Managed; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.Tag; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static com.codahale.metrics.MetricRegistry.name; + +/** + * The feature flags manager provides a high-throughput, eventually-consistent view of feature flags. This is the main + * channel through which callers should interact with feature flags. + *

+ * Feature flags are intended to provide temporary control over server-side features (i.e. for migrations or experiments + * with new services). Each flag is identified by a human-readable name (e.g. "invert-nano-flappers") and is either + * active or inactive. Flags (including flags that have not been set) are inactive by default. + */ +public class FeatureFlagsManager implements Managed { + + private final FeatureFlags featureFlagDatabase; + private final ScheduledExecutorService refreshExecutorService; + private ScheduledFuture refreshFuture; + private final AtomicReference> featureFlags = new AtomicReference<>(Collections.emptyMap()); + + private static final String GAUGE_NAME = "status"; + private static final String FLAG_TAG_NAME = "flag"; + + private static final Duration REFRESH_INTERVAL = Duration.ofSeconds(30); + + public FeatureFlagsManager(final FeatureFlags featureFlagDatabase, final ScheduledExecutorService refreshExecutorService) { + this.featureFlagDatabase = featureFlagDatabase; + this.refreshExecutorService = refreshExecutorService; + + refreshFeatureFlags(); + } + + @Override + public void start() { + refreshFuture = refreshExecutorService.scheduleAtFixedRate(this::refreshFeatureFlags, 0, REFRESH_INTERVAL.toSeconds(), TimeUnit.SECONDS); + } + + @Override + public void stop() { + refreshFuture.cancel(false); + } + + public boolean isFeatureFlagActive(final String featureFlag) { + return featureFlags.get().getOrDefault(featureFlag, false); + } + + public void setFeatureFlag(final String featureFlag, final boolean active) { + featureFlagDatabase.setFlag(featureFlag, active); + refreshFeatureFlags(); + } + + public void deleteFeatureFlag(final String featureFlag) { + featureFlagDatabase.deleteFlag(featureFlag); + refreshFeatureFlags(); + } + + @VisibleForTesting + void refreshFeatureFlags() { + final Map refreshedFeatureFlags = featureFlagDatabase.getFeatureFlags(); + featureFlags.set(refreshedFeatureFlags); + + for (final Map.Entry entry : refreshedFeatureFlags.entrySet()) { + final String featureFlag = entry.getKey(); + final boolean active = entry.getValue(); + + Metrics.gauge(name(getClass(), GAUGE_NAME), List.of(Tag.of(FLAG_TAG_NAME, featureFlag)), active ? 1 : 0); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/VacuumCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/VacuumCommand.java index 43dd55d5f..c42bd7532 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/VacuumCommand.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/workers/VacuumCommand.java @@ -8,6 +8,7 @@ import org.whispersystems.textsecuregcm.WhisperServerConfiguration; import org.whispersystems.textsecuregcm.configuration.DatabaseConfiguration; import org.whispersystems.textsecuregcm.storage.Accounts; import org.whispersystems.textsecuregcm.storage.FaultTolerantDatabase; +import org.whispersystems.textsecuregcm.storage.FeatureFlags; import org.whispersystems.textsecuregcm.storage.Keys; import org.whispersystems.textsecuregcm.storage.Messages; import org.whispersystems.textsecuregcm.storage.PendingAccounts; @@ -43,6 +44,7 @@ public class VacuumCommand extends ConfiguredCommand Keys keys = new Keys(accountDatabase); PendingAccounts pendingAccounts = new PendingAccounts(accountDatabase); Messages messages = new Messages(messageDatabase); + FeatureFlags featureFlags = new FeatureFlags(accountDatabase); logger.info("Vacuuming accounts..."); accounts.vacuum(); @@ -56,6 +58,9 @@ public class VacuumCommand extends ConfiguredCommand logger.info("Vacuuming messages..."); messages.vacuum(); + logger.info("Vacuuming feature flags..."); + featureFlags.vacuum(); + Thread.sleep(3000); System.exit(0); } diff --git a/service/src/main/resources/accountsdb.xml b/service/src/main/resources/accountsdb.xml index 4e958c360..07329ecb6 100644 --- a/service/src/main/resources/accountsdb.xml +++ b/service/src/main/resources/accountsdb.xml @@ -324,4 +324,14 @@ + + + + + + + + + + diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/FeatureFlagsControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/FeatureFlagsControllerTest.java new file mode 100644 index 000000000..a80670d1d --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/FeatureFlagsControllerTest.java @@ -0,0 +1,116 @@ +package org.whispersystems.textsecuregcm.controllers; + +import com.google.common.collect.ImmutableSet; +import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; +import io.dropwizard.testing.junit.ResourceTestRule; +import junitparams.JUnitParamsRunner; +import junitparams.Parameters; +import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccount; +import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.FeatureFlagsManager; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; + +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.Form; +import javax.ws.rs.core.Response; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@RunWith(JUnitParamsRunner.class) +public class FeatureFlagsControllerTest { + + private static final FeatureFlagsManager FEATURE_FLAG_MANAGER = mock(FeatureFlagsManager.class); + private static final FeatureFlagsController FEATURE_FLAG_CONTROLLER = new FeatureFlagsController(FEATURE_FLAG_MANAGER, List.of("first", "second")); + + @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(FEATURE_FLAG_CONTROLLER) + .build(); + + @Before + public void setUp() { + reset(FEATURE_FLAG_MANAGER); + } + + @Test + public void testSet() { + { + final Response response = resources.getJerseyTest() + .target("/v1/featureflag/testFlag") + .request() + .header("Token", "first") + .put(Entity.form(new Form().param("active", "true"))); + + assertEquals(204, response.getStatus()); + verify(FEATURE_FLAG_MANAGER).setFeatureFlag("testFlag", true); + } + + { + final Response response = resources.getJerseyTest() + .target("/v1/featureflag/testFlag") + .request() + .header("Token", "bogus-token") + .put(Entity.form(new Form().param("active", "true"))); + + assertEquals(401, response.getStatus()); + verifyNoMoreInteractions(FEATURE_FLAG_MANAGER); + } + } + + @Test + public void testDelete() { + { + final Response response = resources.getJerseyTest() + .target("/v1/featureflag/testFlag") + .request() + .header("Token", "first") + .delete(); + + assertEquals(204, response.getStatus()); + verify(FEATURE_FLAG_MANAGER).deleteFeatureFlag("testFlag"); + } + + { + final Response response = resources.getJerseyTest() + .target("/v1/featureflag/testFlag") + .request() + .header("Token", "bogus-token") + .delete(); + + assertEquals(401, response.getStatus()); + verifyNoMoreInteractions(FEATURE_FLAG_MANAGER); + } + } + + @Test + @Parameters(method = "argumentsForTestIsAuthorized") + public void testIsAuthorized(final String token, final boolean expectAuthorized) { + assertEquals(expectAuthorized, FEATURE_FLAG_CONTROLLER.isAuthorized(token)); + } + + @SuppressWarnings("unused") + private Object argumentsForTestIsAuthorized() { + return new Object[] { + new Object[] { "first", true }, + new Object[] { "second", true }, + new Object[] { "third", false }, + new Object[] { "firstfirstfirst", false }, + new Object[] { null, false } + }; + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/FeatureFlagsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/FeatureFlagsManagerTest.java new file mode 100644 index 000000000..fd2ff031d --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/FeatureFlagsManagerTest.java @@ -0,0 +1,44 @@ +package org.whispersystems.textsecuregcm.storage; + +import org.junit.Before; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class FeatureFlagsManagerTest { + + private FeatureFlags featureFlagDatabase; + private FeatureFlagsManager featureFlagsManager; + + @Before + public void setUp() { + featureFlagDatabase = mock(FeatureFlags.class); + featureFlagsManager = new FeatureFlagsManager(featureFlagDatabase, mock(ScheduledExecutorService.class)); + } + + @Test + public void testIsFeatureFlagActive() { + final Map featureFlags = new HashMap<>(); + featureFlags.put("testFlag", true); + + when(featureFlagDatabase.getFeatureFlags()).thenReturn(featureFlags); + + assertFalse(featureFlagsManager.isFeatureFlagActive("testFlag")); + + featureFlagsManager.refreshFeatureFlags(); + + assertTrue(featureFlagsManager.isFeatureFlagActive("testFlag")); + + featureFlags.put("testFlag", false); + featureFlagsManager.refreshFeatureFlags(); + + assertFalse(featureFlagsManager.isFeatureFlagActive("testFlag")); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/FeatureFlagsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/FeatureFlagsTest.java new file mode 100644 index 000000000..f9615ad9b --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/FeatureFlagsTest.java @@ -0,0 +1,63 @@ +package org.whispersystems.textsecuregcm.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 java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class FeatureFlagsTest { + + @Rule + public PreparedDbRule db = EmbeddedPostgresRules.preparedDatabase(LiquibasePreparer.forClasspathLocation("accountsdb.xml")); + + private FeatureFlags featureFlags; + + @Before + public void setUp() { + final FaultTolerantDatabase database = new FaultTolerantDatabase("featureFlagsTest", + Jdbi.create(db.getTestDatabase()), + new CircuitBreakerConfiguration()); + + this.featureFlags = new FeatureFlags(database); + } + + @Test + public void testSetFlagIsFlagActive() { + assertTrue(featureFlags.getFeatureFlags().isEmpty()); + + featureFlags.setFlag("testFlag", true); + assertEquals(Map.of("testFlag", true), featureFlags.getFeatureFlags()); + + featureFlags.setFlag("testFlag", false); + assertEquals(Map.of("testFlag", false), featureFlags.getFeatureFlags()); + } + + @Test + public void testDeleteFlag() { + assertTrue(featureFlags.getFeatureFlags().isEmpty()); + + featureFlags.setFlag("testFlag", true); + assertEquals(Map.of("testFlag", true), featureFlags.getFeatureFlags()); + + featureFlags.deleteFlag("testFlag"); + assertTrue(featureFlags.getFeatureFlags().isEmpty()); + } + + @Test + public void testVacuum() { + featureFlags.setFlag("testFlag", true); + assertEquals(Map.of("testFlag", true), featureFlags.getFeatureFlags()); + + featureFlags.vacuum(); + assertEquals(Map.of("testFlag", true), featureFlags.getFeatureFlags()); + } +}