Support ID token at `PUT /v1/config` and `DELETE /v1/config`
This commit is contained in:
parent
f17de58a71
commit
d1e38737ce
|
@ -24,7 +24,7 @@ sealed interface Event
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RemoteConfigSetEvent(
|
data class RemoteConfigSetEvent(
|
||||||
val token: String,
|
val identity: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val percentage: Int,
|
val percentage: Int,
|
||||||
val defaultValue: String? = null,
|
val defaultValue: String? = null,
|
||||||
|
@ -35,6 +35,6 @@ data class RemoteConfigSetEvent(
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RemoteConfigDeleteEvent(
|
data class RemoteConfigDeleteEvent(
|
||||||
val token: String,
|
val identity: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
) : Event
|
) : Event
|
||||||
|
|
|
@ -302,6 +302,17 @@ appConfig:
|
||||||
|
|
||||||
remoteConfig:
|
remoteConfig:
|
||||||
authorizedTokens: secret://remoteConfig.authorizedTokens
|
authorizedTokens: secret://remoteConfig.authorizedTokens
|
||||||
|
authorizedUsers:
|
||||||
|
- # 1st authorized user
|
||||||
|
- # 2nd authorized user
|
||||||
|
- # ...
|
||||||
|
- # Nth authorized user
|
||||||
|
requiredHostedDomain: example.com
|
||||||
|
audiences:
|
||||||
|
- # 1st audience
|
||||||
|
- # 2nd audience
|
||||||
|
- # ...
|
||||||
|
- # Nth audience
|
||||||
globalConfig: # keys and values that are given to clients on GET /v1/config
|
globalConfig: # keys and values that are given to clients on GET /v1/config
|
||||||
EXAMPLE_KEY: VALUE
|
EXAMPLE_KEY: VALUE
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,9 @@ import com.amazonaws.ClientConfiguration;
|
||||||
import com.amazonaws.auth.AWSCredentialsProviderChain;
|
import com.amazonaws.auth.AWSCredentialsProviderChain;
|
||||||
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;
|
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;
|
||||||
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;
|
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;
|
||||||
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||||
|
import com.google.api.client.http.apache.v2.ApacheHttpTransport;
|
||||||
|
import com.google.api.client.json.gson.GsonFactory;
|
||||||
import com.google.auth.oauth2.GoogleCredentials;
|
import com.google.auth.oauth2.GoogleCredentials;
|
||||||
import com.google.cloud.logging.LoggingOptions;
|
import com.google.cloud.logging.LoggingOptions;
|
||||||
import com.google.common.collect.ImmutableMap;
|
import com.google.common.collect.ImmutableMap;
|
||||||
|
@ -753,6 +756,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
keys, rateLimiters),
|
keys, rateLimiters),
|
||||||
new RemoteConfigController(remoteConfigsManager, adminEventLogger,
|
new RemoteConfigController(remoteConfigsManager, adminEventLogger,
|
||||||
config.getRemoteConfigConfiguration().authorizedTokens().value(),
|
config.getRemoteConfigConfiguration().authorizedTokens().value(),
|
||||||
|
config.getRemoteConfigConfiguration().authorizedUsers(),
|
||||||
|
config.getRemoteConfigConfiguration().requiredHostedDomain(),
|
||||||
|
config.getRemoteConfigConfiguration().audiences(),
|
||||||
|
new GoogleIdTokenVerifier.Builder(new ApacheHttpTransport(), new GsonFactory()),
|
||||||
config.getRemoteConfigConfiguration().globalConfig()),
|
config.getRemoteConfigConfiguration().globalConfig()),
|
||||||
new SecureBackupController(backupCredentialsGenerator, accountsManager),
|
new SecureBackupController(backupCredentialsGenerator, accountsManager),
|
||||||
new SecureStorageController(storageCredentialsGenerator),
|
new SecureStorageController(storageCredentialsGenerator),
|
||||||
|
|
|
@ -5,10 +5,17 @@
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.configuration;
|
package org.whispersystems.textsecuregcm.configuration;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import javax.validation.constraints.NotEmpty;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretStringList;
|
import org.whispersystems.textsecuregcm.configuration.secrets.SecretStringList;
|
||||||
|
|
||||||
public record RemoteConfigConfiguration(@NotNull SecretStringList authorizedTokens,
|
public record RemoteConfigConfiguration(@NotNull SecretStringList authorizedTokens,
|
||||||
|
@NotNull Set<String> authorizedUsers,
|
||||||
|
@NotNull String requiredHostedDomain,
|
||||||
|
@NotNull @NotEmpty List<String> audiences,
|
||||||
@NotNull Map<String, String> globalConfig) {
|
@NotNull Map<String, String> globalConfig) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,8 @@
|
||||||
package org.whispersystems.textsecuregcm.controllers;
|
package org.whispersystems.textsecuregcm.controllers;
|
||||||
|
|
||||||
import com.codahale.metrics.annotation.Timed;
|
import com.codahale.metrics.annotation.Timed;
|
||||||
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
||||||
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import io.dropwizard.auth.Auth;
|
import io.dropwizard.auth.Auth;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
@ -16,10 +18,12 @@ import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
import javax.ws.rs.Consumes;
|
import javax.ws.rs.Consumes;
|
||||||
|
@ -51,15 +55,26 @@ public class RemoteConfigController {
|
||||||
private final RemoteConfigsManager remoteConfigsManager;
|
private final RemoteConfigsManager remoteConfigsManager;
|
||||||
private final AdminEventLogger adminEventLogger;
|
private final AdminEventLogger adminEventLogger;
|
||||||
private final List<String> configAuthTokens;
|
private final List<String> configAuthTokens;
|
||||||
|
private final Set<String> configAuthUsers;
|
||||||
private final Map<String, String> globalConfig;
|
private final Map<String, String> globalConfig;
|
||||||
|
|
||||||
|
private final String requiredHostedDomain;
|
||||||
|
|
||||||
|
private final GoogleIdTokenVerifier googleIdTokenVerifier;
|
||||||
|
|
||||||
private static final String GLOBAL_CONFIG_PREFIX = "global.";
|
private static final String GLOBAL_CONFIG_PREFIX = "global.";
|
||||||
|
|
||||||
public RemoteConfigController(RemoteConfigsManager remoteConfigsManager, AdminEventLogger adminEventLogger, List<String> configAuthTokens, Map<String, String> globalConfig) {
|
public RemoteConfigController(RemoteConfigsManager remoteConfigsManager, AdminEventLogger adminEventLogger,
|
||||||
|
List<String> configAuthTokens, Set<String> configAuthUsers, String requiredHostedDomain, List<String> audience,
|
||||||
|
final GoogleIdTokenVerifier.Builder googleIdTokenVerifierBuilder, Map<String, String> globalConfig) {
|
||||||
this.remoteConfigsManager = remoteConfigsManager;
|
this.remoteConfigsManager = remoteConfigsManager;
|
||||||
this.adminEventLogger = Objects.requireNonNull(adminEventLogger);
|
this.adminEventLogger = Objects.requireNonNull(adminEventLogger);
|
||||||
this.configAuthTokens = configAuthTokens;
|
this.configAuthTokens = configAuthTokens;
|
||||||
|
this.configAuthUsers = configAuthUsers;
|
||||||
this.globalConfig = globalConfig;
|
this.globalConfig = globalConfig;
|
||||||
|
|
||||||
|
this.requiredHostedDomain = requiredHostedDomain;
|
||||||
|
this.googleIdTokenVerifier = googleIdTokenVerifierBuilder.setAudience(audience).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Timed
|
@Timed
|
||||||
|
@ -89,9 +104,9 @@ public class RemoteConfigController {
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public void set(@HeaderParam("Config-Token") String configToken, @NotNull @Valid RemoteConfig config) {
|
public void set(@HeaderParam("Config-Token") String configToken, @NotNull @Valid RemoteConfig config) {
|
||||||
if (!isAuthorized(configToken)) {
|
|
||||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
final String authIdentity = getAuthIdentity(configToken)
|
||||||
}
|
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||||
|
|
||||||
if (config.getName().startsWith(GLOBAL_CONFIG_PREFIX)) {
|
if (config.getName().startsWith(GLOBAL_CONFIG_PREFIX)) {
|
||||||
throw new WebApplicationException(Response.Status.FORBIDDEN);
|
throw new WebApplicationException(Response.Status.FORBIDDEN);
|
||||||
|
@ -99,7 +114,7 @@ public class RemoteConfigController {
|
||||||
|
|
||||||
adminEventLogger.logEvent(
|
adminEventLogger.logEvent(
|
||||||
new RemoteConfigSetEvent(
|
new RemoteConfigSetEvent(
|
||||||
configToken,
|
authIdentity,
|
||||||
config.getName(),
|
config.getName(),
|
||||||
config.getPercentage(),
|
config.getPercentage(),
|
||||||
config.getDefaultValue(),
|
config.getDefaultValue(),
|
||||||
|
@ -113,21 +128,48 @@ public class RemoteConfigController {
|
||||||
@DELETE
|
@DELETE
|
||||||
@Path("/{name}")
|
@Path("/{name}")
|
||||||
public void delete(@HeaderParam("Config-Token") String configToken, @PathParam("name") String name) {
|
public void delete(@HeaderParam("Config-Token") String configToken, @PathParam("name") String name) {
|
||||||
if (!isAuthorized(configToken)) {
|
final String authIdentity = getAuthIdentity(configToken)
|
||||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||||
}
|
|
||||||
|
|
||||||
if (name.startsWith(GLOBAL_CONFIG_PREFIX)) {
|
if (name.startsWith(GLOBAL_CONFIG_PREFIX)) {
|
||||||
throw new WebApplicationException(Response.Status.FORBIDDEN);
|
throw new WebApplicationException(Response.Status.FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
adminEventLogger.logEvent(new RemoteConfigDeleteEvent(configToken, name));
|
adminEventLogger.logEvent(new RemoteConfigDeleteEvent(authIdentity, name));
|
||||||
remoteConfigsManager.delete(name);
|
remoteConfigsManager.delete(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Optional<String> getAuthIdentity(String token) {
|
||||||
|
return getAuthorizedGoogleIdentity(token)
|
||||||
|
.map(googleIdToken -> googleIdToken.getPayload().getEmail())
|
||||||
|
.or(() -> Optional.ofNullable(isAuthorized(token) ? token : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<GoogleIdToken> getAuthorizedGoogleIdentity(String token) {
|
||||||
|
try {
|
||||||
|
final @Nullable GoogleIdToken googleIdToken = googleIdTokenVerifier.verify(token);
|
||||||
|
|
||||||
|
if (googleIdToken != null
|
||||||
|
&& googleIdToken.getPayload().getHostedDomain().equals(requiredHostedDomain)
|
||||||
|
&& googleIdToken.getPayload().getEmailVerified()
|
||||||
|
&& configAuthUsers.contains(googleIdToken.getPayload().getEmail())) {
|
||||||
|
|
||||||
|
return Optional.of(googleIdToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.empty();
|
||||||
|
|
||||||
|
} catch (final Exception ignored) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
public static boolean isInBucket(MessageDigest digest, UUID uid, byte[] hashKey, int configPercentage, Set<UUID> uuidsInBucket) {
|
public static boolean isInBucket(MessageDigest digest, UUID uid, byte[] hashKey, int configPercentage,
|
||||||
if (uuidsInBucket.contains(uid)) return true;
|
Set<UUID> uuidsInBucket) {
|
||||||
|
if (uuidsInBucket.contains(uid)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
|
ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
|
||||||
bb.putLong(uid.getMostSignificantBits());
|
bb.putLong(uid.getMostSignificantBits());
|
||||||
|
@ -135,8 +177,8 @@ public class RemoteConfigController {
|
||||||
|
|
||||||
digest.update(bb.array());
|
digest.update(bb.array());
|
||||||
|
|
||||||
byte[] hash = digest.digest(hashKey);
|
byte[] hash = digest.digest(hashKey);
|
||||||
int bucket = (int)(Util.ensureNonNegativeLong(Conversions.byteArrayToLong(hash)) % 100);
|
int bucket = (int) (Util.ensureNonNegativeLong(Conversions.byteArrayToLong(hash)) % 100);
|
||||||
|
|
||||||
return bucket < configPercentage;
|
return bucket < configPercentage;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,8 @@
|
||||||
package org.whispersystems.textsecuregcm.tests.controllers;
|
package org.whispersystems.textsecuregcm.tests.controllers;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.reset;
|
import static org.mockito.Mockito.reset;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
|
@ -13,12 +15,15 @@ import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
||||||
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||||
import com.google.common.collect.ImmutableSet;
|
import com.google.common.collect.ImmutableSet;
|
||||||
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
|
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
|
||||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
|
@ -26,6 +31,7 @@ import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Random;
|
import java.util.Random;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.stream.Stream;
|
||||||
import javax.ws.rs.client.Entity;
|
import javax.ws.rs.client.Entity;
|
||||||
import javax.ws.rs.core.MediaType;
|
import javax.ws.rs.core.MediaType;
|
||||||
import javax.ws.rs.core.Response;
|
import javax.ws.rs.core.Response;
|
||||||
|
@ -34,6 +40,9 @@ import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.signal.event.NoOpAdminEventLogger;
|
import org.signal.event.NoOpAdminEventLogger;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||||
|
@ -50,7 +59,19 @@ import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||||
class RemoteConfigControllerTest {
|
class RemoteConfigControllerTest {
|
||||||
|
|
||||||
private static final RemoteConfigsManager remoteConfigsManager = mock(RemoteConfigsManager.class);
|
private static final RemoteConfigsManager remoteConfigsManager = mock(RemoteConfigsManager.class);
|
||||||
private static final List<String> remoteConfigsAuth = List.of("foo", "bar");
|
private static final List<String> remoteConfigsAuth = List.of("foo", "bar");
|
||||||
|
|
||||||
|
private static final Set<String> remoteConfigsUsers = Set.of("user1@example.com", "user2@example.com");
|
||||||
|
|
||||||
|
private static final String requiredHostedDomain = "example.com";
|
||||||
|
private static final GoogleIdTokenVerifier.Builder googleIdVerificationTokenBuilder = mock(
|
||||||
|
GoogleIdTokenVerifier.Builder.class);
|
||||||
|
private static final GoogleIdTokenVerifier googleIdTokenVerifier = mock(GoogleIdTokenVerifier.class);
|
||||||
|
|
||||||
|
static {
|
||||||
|
when(googleIdVerificationTokenBuilder.setAudience(any())).thenReturn(googleIdVerificationTokenBuilder);
|
||||||
|
when(googleIdVerificationTokenBuilder.build()).thenReturn(googleIdTokenVerifier);
|
||||||
|
}
|
||||||
|
|
||||||
private static final ResourceExtension resources = ResourceExtension.builder()
|
private static final ResourceExtension resources = ResourceExtension.builder()
|
||||||
.addProvider(AuthHelper.getAuthFilter())
|
.addProvider(AuthHelper.getAuthFilter())
|
||||||
|
@ -58,14 +79,17 @@ class RemoteConfigControllerTest {
|
||||||
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
|
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
|
||||||
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||||
.addProvider(new DeviceLimitExceededExceptionMapper())
|
.addProvider(new DeviceLimitExceededExceptionMapper())
|
||||||
.addResource(new RemoteConfigController(remoteConfigsManager, new NoOpAdminEventLogger(), remoteConfigsAuth, Map.of("maxGroupSize", "42")))
|
.addResource(new RemoteConfigController(remoteConfigsManager, new NoOpAdminEventLogger(), remoteConfigsAuth,
|
||||||
|
remoteConfigsUsers, requiredHostedDomain, Collections.singletonList("aud.example.com"),
|
||||||
|
googleIdVerificationTokenBuilder, Map.of("maxGroupSize", "42")))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setup() {
|
void setup() throws Exception {
|
||||||
when(remoteConfigsManager.getAll()).thenReturn(new LinkedList<>() {{
|
when(remoteConfigsManager.getAll()).thenReturn(new LinkedList<>() {{
|
||||||
add(new RemoteConfig("android.stickers", 25, Set.of(AuthHelper.DISABLED_UUID, AuthHelper.INVALID_UUID), null, null, null));
|
add(new RemoteConfig("android.stickers", 25, Set.of(AuthHelper.DISABLED_UUID, AuthHelper.INVALID_UUID), null,
|
||||||
|
null, null));
|
||||||
add(new RemoteConfig("ios.stickers", 50, Set.of(), null, null, null));
|
add(new RemoteConfig("ios.stickers", 50, Set.of(), null, null, null));
|
||||||
add(new RemoteConfig("always.true", 100, Set.of(), null, null, null));
|
add(new RemoteConfig("always.true", 100, Set.of(), null, null, null));
|
||||||
add(new RemoteConfig("only.special", 0, Set.of(AuthHelper.VALID_UUID), null, null, null));
|
add(new RemoteConfig("only.special", 0, Set.of(AuthHelper.VALID_UUID), null, null, null));
|
||||||
|
@ -76,6 +100,24 @@ class RemoteConfigControllerTest {
|
||||||
add(new RemoteConfig("linked.config.1", 50, Set.of(), null, null, "linked.config.0"));
|
add(new RemoteConfig("linked.config.1", 50, Set.of(), null, null, "linked.config.0"));
|
||||||
add(new RemoteConfig("unlinked.config", 50, Set.of(), null, null, null));
|
add(new RemoteConfig("unlinked.config", 50, Set.of(), null, null, null));
|
||||||
}});
|
}});
|
||||||
|
|
||||||
|
final Map<String, GoogleIdToken> googleIdTokens = new HashMap<>();
|
||||||
|
|
||||||
|
for (int i = 1; i <= 3; i++) {
|
||||||
|
final String user = "user" + i;
|
||||||
|
final GoogleIdToken googleIdToken = mock(GoogleIdToken.class);
|
||||||
|
final GoogleIdToken.Payload payload = mock(GoogleIdToken.Payload.class);
|
||||||
|
when(googleIdToken.getPayload()).thenReturn(payload);
|
||||||
|
|
||||||
|
when(payload.getEmail()).thenReturn(user + "@" + requiredHostedDomain);
|
||||||
|
when(payload.getEmailVerified()).thenReturn(true);
|
||||||
|
when(payload.getHostedDomain()).thenReturn(requiredHostedDomain);
|
||||||
|
|
||||||
|
googleIdTokens.put(user + ".valid", googleIdToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
when(googleIdTokenVerifier.verify(anyString()))
|
||||||
|
.thenAnswer(answer -> googleIdTokens.get(answer.getArgument(0, String.class)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
|
@ -173,28 +215,28 @@ class RemoteConfigControllerTest {
|
||||||
assertThat(allUnlinkedConfigsMatched).isFalse();
|
assertThat(allUnlinkedConfigsMatched).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testRetrieveConfigUnauthorized() {
|
void testRetrieveConfigUnauthorized() {
|
||||||
Response response = resources.getJerseyTest()
|
Response response = resources.getJerseyTest()
|
||||||
.target("/v1/config/")
|
.target("/v1/config/")
|
||||||
.request()
|
.request()
|
||||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD))
|
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(401);
|
assertThat(response.getStatus()).isEqualTo(401);
|
||||||
|
|
||||||
verifyNoMoreInteractions(remoteConfigsManager);
|
verifyNoMoreInteractions(remoteConfigsManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
@Test
|
@MethodSource("authorizedTokens")
|
||||||
void testSetConfig() {
|
void testSetConfig(final String configToken) {
|
||||||
Response response = resources.getJerseyTest()
|
Response response = resources.getJerseyTest()
|
||||||
.target("/v1/config")
|
.target("/v1/config")
|
||||||
.request()
|
.request()
|
||||||
.header("Config-Token", "foo")
|
.header("Config-Token", configToken)
|
||||||
.put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE));
|
.put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null),
|
||||||
|
MediaType.APPLICATION_JSON_TYPE));
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(204);
|
assertThat(response.getStatus()).isEqualTo(204);
|
||||||
|
|
||||||
|
@ -207,13 +249,15 @@ class RemoteConfigControllerTest {
|
||||||
assertThat(captor.getValue().getUuids()).isEmpty();
|
assertThat(captor.getValue().getUuids()).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testSetConfigValued() {
|
@MethodSource("authorizedTokens")
|
||||||
|
void testSetConfigValued(final String configToken) {
|
||||||
Response response = resources.getJerseyTest()
|
Response response = resources.getJerseyTest()
|
||||||
.target("/v1/config")
|
.target("/v1/config")
|
||||||
.request()
|
.request()
|
||||||
.header("Config-Token", "foo")
|
.header("Config-Token", configToken)
|
||||||
.put(Entity.entity(new RemoteConfig("value.sometimes", 50, Set.of(), "a", "b", null), MediaType.APPLICATION_JSON_TYPE));
|
.put(Entity.entity(new RemoteConfig("value.sometimes", 50, Set.of(), "a", "b", null),
|
||||||
|
MediaType.APPLICATION_JSON_TYPE));
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(204);
|
assertThat(response.getStatus()).isEqualTo(204);
|
||||||
|
|
||||||
|
@ -226,19 +270,21 @@ class RemoteConfigControllerTest {
|
||||||
assertThat(captor.getValue().getUuids()).isEmpty();
|
assertThat(captor.getValue().getUuids()).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testSetConfigWithHashKey() {
|
@MethodSource("authorizedTokens")
|
||||||
|
void testSetConfigWithHashKey(final String configToken) {
|
||||||
Response response1 = resources.getJerseyTest()
|
Response response1 = resources.getJerseyTest()
|
||||||
.target("/v1/config")
|
.target("/v1/config")
|
||||||
.request()
|
.request()
|
||||||
.header("Config-Token", "foo")
|
.header("Config-Token", configToken)
|
||||||
.put(Entity.entity(new RemoteConfig("linked.config.0", 50, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE));
|
.put(Entity.entity(new RemoteConfig("linked.config.0", 50, Set.of(), "FALSE", "TRUE", null),
|
||||||
|
MediaType.APPLICATION_JSON_TYPE));
|
||||||
assertThat(response1.getStatus()).isEqualTo(204);
|
assertThat(response1.getStatus()).isEqualTo(204);
|
||||||
|
|
||||||
Response response2 = resources.getJerseyTest()
|
Response response2 = resources.getJerseyTest()
|
||||||
.target("/v1/config")
|
.target("/v1/config")
|
||||||
.request()
|
.request()
|
||||||
.header("Config-Token", "foo")
|
.header("Config-Token", configToken)
|
||||||
.put(Entity.entity(new RemoteConfig("linked.config.1", 50, Set.of(), "FALSE", "TRUE", "linked.config.0"), MediaType.APPLICATION_JSON_TYPE));
|
.put(Entity.entity(new RemoteConfig("linked.config.1", 50, Set.of(), "FALSE", "TRUE", "linked.config.0"), MediaType.APPLICATION_JSON_TYPE));
|
||||||
assertThat(response2.getStatus()).isEqualTo(204);
|
assertThat(response2.getStatus()).isEqualTo(204);
|
||||||
|
|
||||||
|
@ -262,13 +308,15 @@ class RemoteConfigControllerTest {
|
||||||
assertThat(capture2.getHashKey()).isEqualTo("linked.config.0");
|
assertThat(capture2.getHashKey()).isEqualTo("linked.config.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testSetConfigUnauthorized() {
|
@MethodSource("unauthorizedTokens")
|
||||||
|
void testSetConfigUnauthorized(final String configToken) {
|
||||||
Response response = resources.getJerseyTest()
|
Response response = resources.getJerseyTest()
|
||||||
.target("/v1/config")
|
.target("/v1/config")
|
||||||
.request()
|
.request()
|
||||||
.header("Config-Token", "baz")
|
.header("Config-Token", configToken)
|
||||||
.put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE));
|
.put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null),
|
||||||
|
MediaType.APPLICATION_JSON_TYPE));
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(401);
|
assertThat(response.getStatus()).isEqualTo(401);
|
||||||
|
|
||||||
|
@ -278,59 +326,66 @@ class RemoteConfigControllerTest {
|
||||||
@Test
|
@Test
|
||||||
void testSetConfigMissingUnauthorized() {
|
void testSetConfigMissingUnauthorized() {
|
||||||
Response response = resources.getJerseyTest()
|
Response response = resources.getJerseyTest()
|
||||||
.target("/v1/config")
|
.target("/v1/config")
|
||||||
.request()
|
.request()
|
||||||
.put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE));
|
.put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null),
|
||||||
|
MediaType.APPLICATION_JSON_TYPE));
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(401);
|
assertThat(response.getStatus()).isEqualTo(401);
|
||||||
|
|
||||||
verifyNoMoreInteractions(remoteConfigsManager);
|
verifyNoMoreInteractions(remoteConfigsManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testSetConfigBadName() {
|
@MethodSource("authorizedTokens")
|
||||||
|
void testSetConfigBadName(final String configToken) {
|
||||||
Response response = resources.getJerseyTest()
|
Response response = resources.getJerseyTest()
|
||||||
.target("/v1/config")
|
.target("/v1/config")
|
||||||
.request()
|
.request()
|
||||||
.header("Config-Token", "foo")
|
.header("Config-Token", configToken)
|
||||||
.put(Entity.entity(new RemoteConfig("android-stickers", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE));
|
.put(Entity.entity(new RemoteConfig("android-stickers", 88, Set.of(), "FALSE", "TRUE", null),
|
||||||
|
MediaType.APPLICATION_JSON_TYPE));
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(422);
|
assertThat(response.getStatus()).isEqualTo(422);
|
||||||
|
|
||||||
verifyNoMoreInteractions(remoteConfigsManager);
|
verifyNoMoreInteractions(remoteConfigsManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testSetConfigEmptyName() {
|
@MethodSource("authorizedTokens")
|
||||||
|
void testSetConfigEmptyName(final String configToken) {
|
||||||
Response response = resources.getJerseyTest()
|
Response response = resources.getJerseyTest()
|
||||||
.target("/v1/config")
|
.target("/v1/config")
|
||||||
.request()
|
.request()
|
||||||
.header("Config-Token", "foo")
|
.header("Config-Token", configToken)
|
||||||
.put(Entity.entity(new RemoteConfig("", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE));
|
.put(Entity.entity(new RemoteConfig("", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE));
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(422);
|
assertThat(response.getStatus()).isEqualTo(422);
|
||||||
|
|
||||||
verifyNoMoreInteractions(remoteConfigsManager);
|
verifyNoMoreInteractions(remoteConfigsManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testSetGlobalConfig() {
|
@MethodSource("authorizedTokens")
|
||||||
|
void testSetGlobalConfig(final String configToken) {
|
||||||
Response response = resources.getJerseyTest()
|
Response response = resources.getJerseyTest()
|
||||||
.target("/v1/config")
|
.target("/v1/config")
|
||||||
.request()
|
.request()
|
||||||
.header("Config-Token", "foo")
|
.header("Config-Token", configToken)
|
||||||
.put(Entity.entity(new RemoteConfig("global.maxGroupSize", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE));
|
.put(Entity.entity(new RemoteConfig("global.maxGroupSize", 88, Set.of(), "FALSE", "TRUE", null),
|
||||||
|
MediaType.APPLICATION_JSON_TYPE));
|
||||||
assertThat(response.getStatus()).isEqualTo(403);
|
assertThat(response.getStatus()).isEqualTo(403);
|
||||||
verifyNoMoreInteractions(remoteConfigsManager);
|
verifyNoMoreInteractions(remoteConfigsManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testDelete() {
|
@MethodSource("authorizedTokens")
|
||||||
|
void testDelete(final String configToken) {
|
||||||
Response response = resources.getJerseyTest()
|
Response response = resources.getJerseyTest()
|
||||||
.target("/v1/config/android.stickers")
|
.target("/v1/config/android.stickers")
|
||||||
.request()
|
.request()
|
||||||
.header("Config-Token", "foo")
|
.header("Config-Token", configToken)
|
||||||
.delete();
|
.delete();
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(204);
|
assertThat(response.getStatus()).isEqualTo(204);
|
||||||
|
|
||||||
|
@ -341,23 +396,24 @@ class RemoteConfigControllerTest {
|
||||||
@Test
|
@Test
|
||||||
void testDeleteUnauthorized() {
|
void testDeleteUnauthorized() {
|
||||||
Response response = resources.getJerseyTest()
|
Response response = resources.getJerseyTest()
|
||||||
.target("/v1/config/android.stickers")
|
.target("/v1/config/android.stickers")
|
||||||
.request()
|
.request()
|
||||||
.header("Config-Token", "baz")
|
.header("Config-Token", "baz")
|
||||||
.delete();
|
.delete();
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(401);
|
assertThat(response.getStatus()).isEqualTo(401);
|
||||||
|
|
||||||
verifyNoMoreInteractions(remoteConfigsManager);
|
verifyNoMoreInteractions(remoteConfigsManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testDeleteGlobalConfig() {
|
@MethodSource("authorizedTokens")
|
||||||
|
void testDeleteGlobalConfig(final String configToken) {
|
||||||
Response response = resources.getJerseyTest()
|
Response response = resources.getJerseyTest()
|
||||||
.target("/v1/config/global.maxGroupSize")
|
.target("/v1/config/global.maxGroupSize")
|
||||||
.request()
|
.request()
|
||||||
.header("Config-Token", "foo")
|
.header("Config-Token", configToken)
|
||||||
.delete();
|
.delete();
|
||||||
assertThat(response.getStatus()).isEqualTo(403);
|
assertThat(response.getStatus()).isEqualTo(403);
|
||||||
verifyNoMoreInteractions(remoteConfigsManager);
|
verifyNoMoreInteractions(remoteConfigsManager);
|
||||||
}
|
}
|
||||||
|
@ -383,11 +439,25 @@ class RemoteConfigControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (RemoteConfig config : remoteConfigList) {
|
for (RemoteConfig config : remoteConfigList) {
|
||||||
double targetNumber = iterations * (config.getPercentage() / 100.0);
|
double targetNumber = iterations * (config.getPercentage() / 100.0);
|
||||||
double variance = targetNumber * 0.01;
|
double variance = targetNumber * 0.01;
|
||||||
|
|
||||||
assertThat(enabledMap.get(config.getName())).isBetween((int)(targetNumber - variance), (int)(targetNumber + variance));
|
assertThat(enabledMap.get(config.getName())).isBetween((int) (targetNumber - variance),
|
||||||
|
(int) (targetNumber + variance));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Stream<Arguments> authorizedTokens() {
|
||||||
|
return Stream.of(
|
||||||
|
Arguments.of("foo"),
|
||||||
|
Arguments.of("user1.valid")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Stream<Arguments> unauthorizedTokens() {
|
||||||
|
return Stream.of(
|
||||||
|
Arguments.of("baz"),
|
||||||
|
Arguments.of("user3.valid")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue