Add receipt redemption API to chat server

This commit is contained in:
Ehren Kret
2021-10-01 12:44:47 -05:00
committed by GitHub
parent ba58a95a0f
commit 3032415141
24 changed files with 708 additions and 143 deletions

View File

@@ -19,6 +19,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.ListResourceBundle;
import java.util.Locale;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.ResourceBundle.Control;
import java.util.stream.Stream;
@@ -80,7 +81,7 @@ public class ConfiguredProfileBadgeConverterTest {
return objects;
}
};
return new BadgesConfiguration(badges, List.of());
return new BadgesConfiguration(badges, List.of(), Map.of());
}
private BadgeConfiguration getBadge(BadgesConfiguration badgesConfiguration, int i) {

View File

@@ -10,70 +10,151 @@ import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.github.tomakehurst.wiremock.junit.WireMockRule;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import com.google.common.collect.ImmutableSet;
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit5.ResourceExtension;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.zkgroup.receipts.ReceiptSerial;
import org.signal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.configuration.DonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
import org.whispersystems.textsecuregcm.controllers.DonationController;
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationRequest;
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationResponse;
import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.SystemMapper;
public class DonationControllerTest {
private static final Executor executor = Executors.newSingleThreadExecutor();
private static final Executor httpClientExecutor = Executors.newSingleThreadExecutor();
private static final long nowEpochSeconds = 1_500_000_000L;
@Rule
public final WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort().dynamicHttpsPort());
@RegisterExtension
static WireMockExtension wm = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort().dynamicHttpsPort())
.build();
static SecureRandom secureRandom;
static {
try {
secureRandom = SecureRandom.getInstanceStrong();
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
}
private ResourceExtension resources;
@Before
public void before() throws Throwable {
static DonationConfiguration getDonationConfiguration() {
DonationConfiguration configuration = new DonationConfiguration();
configuration.setApiKey("test-api-key");
configuration.setDescription("some description");
configuration.setUri("http://localhost:" + wireMockRule.port() + "/foo/bar");
configuration.setUri("http://localhost:" + wm.getRuntimeInfo().getHttpPort() + "/foo/bar");
configuration.setCircuitBreaker(new CircuitBreakerConfiguration());
configuration.setRetry(new RetryConfiguration());
configuration.setSupportedCurrencies(Set.of("usd", "gbp"));
return configuration;
}
static BadgesConfiguration getBadgesConfiguration() {
return new BadgesConfiguration(
List.of(
new BadgeConfiguration("TEST", "other", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
new BadgeConfiguration("TEST1", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
new BadgeConfiguration("TEST2", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
new BadgeConfiguration("TEST3", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S")),
List.of("TEST"),
Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3"));
}
Clock clock;
ServerZkReceiptOperations zkReceiptOperations;
RedeemedReceiptsManager redeemedReceiptsManager;
AccountsManager accountsManager;
byte[] receiptSerialBytes;
ReceiptSerial receiptSerial;
byte[] presentation;
DonationController.ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
ReceiptCredentialPresentation receiptCredentialPresentation;
ResourceExtension resources;
@BeforeEach
void beforeEach() throws Throwable {
clock = mock(Clock.class);
zkReceiptOperations = mock(ServerZkReceiptOperations.class);
redeemedReceiptsManager = mock(RedeemedReceiptsManager.class);
accountsManager = mock(AccountsManager.class);
AccountsHelper.setupMockUpdate(accountsManager);
receiptSerialBytes = new byte[ReceiptSerial.SIZE];
secureRandom.nextBytes(receiptSerialBytes);
receiptSerial = new ReceiptSerial(receiptSerialBytes);
presentation = new byte[ReceiptCredentialPresentation.SIZE];
secureRandom.nextBytes(presentation);
receiptCredentialPresentationFactory = mock(DonationController.ReceiptCredentialPresentationFactory.class);
receiptCredentialPresentation = mock(ReceiptCredentialPresentation.class);
when(clock.millis()).thenReturn(nowEpochSeconds * 1000L);
when(clock.instant()).thenReturn(Instant.ofEpochSecond(nowEpochSeconds));
try {
when(receiptCredentialPresentationFactory.build(presentation)).thenReturn(receiptCredentialPresentation);
} catch (InvalidInputException e) {
throw new AssertionError(e);
}
resources = ResourceExtension.builder()
.addProvider(AuthHelper.getAuthFilter())
.addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
.setMapper(SystemMapper.getMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new DonationController(executor, configuration))
.addResource(new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager,
getBadgesConfiguration(), receiptCredentialPresentationFactory, httpClientExecutor,
getDonationConfiguration()))
.build();
resources.before();
}
@After
public void after() throws Throwable {
@AfterEach
void afterEach() throws Throwable {
resources.after();
}
@Test
public void testGetApplePayAuthorizationReturns200() {
wireMockRule.stubFor(post(urlEqualTo("/foo/bar"))
void testGetApplePayAuthorizationReturns200() {
wm.stubFor(post(urlEqualTo("/foo/bar"))
.withBasicAuth("test-api-key", "")
.willReturn(aResponse()
.withHeader("Content-Type", MediaType.APPLICATION_JSON)
@@ -96,7 +177,7 @@ public class DonationControllerTest {
}
@Test
public void testGetApplePayAuthorizationWithoutAuthHeaderReturns401() {
void testGetApplePayAuthorizationWithoutAuthHeaderReturns401() {
ApplePayAuthorizationRequest request = new ApplePayAuthorizationRequest();
request.setCurrency("usd");
request.setAmount(1000);
@@ -109,7 +190,7 @@ public class DonationControllerTest {
}
@Test
public void testGetApplePayAuthorizationWithUnsupportedCurrencyReturns422() {
void testGetApplePayAuthorizationWithUnsupportedCurrencyReturns422() {
ApplePayAuthorizationRequest request = new ApplePayAuthorizationRequest();
request.setCurrency("zzz");
request.setAmount(1000);
@@ -121,4 +202,48 @@ public class DonationControllerTest {
assertThat(response.getStatus()).isEqualTo(422);
}
@Test
void testRedeemReceipt() {
when(receiptCredentialPresentation.getReceiptSerial()).thenReturn(receiptSerial);
final long receiptLevel = 1L;
when(receiptCredentialPresentation.getReceiptLevel()).thenReturn(receiptLevel);
final long receiptExpiration = nowEpochSeconds + 86400 * 30;
when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration);
when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn(
CompletableFuture.completedFuture(Boolean.TRUE));
when(accountsManager.get(eq(AuthHelper.VALID_UUID))).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true);
Response response = resources.getJerseyTest()
.target("/v1/donation/redeem-receipt")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
assertThat(response.getStatus()).isEqualTo(200);
verify(AuthHelper.VALID_ACCOUNT).addBadge(same(clock), eq(new AccountBadge("TEST1", Instant.ofEpochSecond(receiptExpiration), true)));
verify(AuthHelper.VALID_ACCOUNT).makeBadgePrimaryIfExists(same(clock), eq("TEST1"));
}
@Test
void testRedeemReceiptAlreadyRedeemedWithDifferentParameters() {
when(receiptCredentialPresentation.getReceiptSerial()).thenReturn(receiptSerial);
final long receiptLevel = 1L;
when(receiptCredentialPresentation.getReceiptLevel()).thenReturn(receiptLevel);
final long receiptExpiration = nowEpochSeconds + 86400 * 30;
when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration);
when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn(
CompletableFuture.completedFuture(Boolean.FALSE));
RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true);
Response response = resources.getJerseyTest()
.target("/v1/donation/redeem-receipt")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.readEntity(String.class)).isEqualTo("receipt serial is already redeemed");
}
}

View File

@@ -26,6 +26,7 @@ import java.time.Clock;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import javax.ws.rs.client.Entity;
@@ -115,7 +116,7 @@ class ProfileControllerTest {
new BadgeConfiguration("TEST1", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
new BadgeConfiguration("TEST2", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
new BadgeConfiguration("TEST3", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S")
), List.of("TEST1")),
), List.of("TEST1"), Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3")),
s3client,
postPolicyGenerator,
policySigner,

View File

@@ -0,0 +1,91 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.tests.storage;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.receipts.ReceiptSerial;
import org.whispersystems.textsecuregcm.storage.DynamoDbExtension;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
class RedeemedReceiptsManagerTest {
private static final long NOW_EPOCH_SECONDS = 1_500_000_000L;
private static final String REDEEMED_RECEIPTS_TABLE_NAME = "redeemed_receipts";
private static final SecureRandom SECURE_RANDOM;
static {
try {
SECURE_RANDOM = SecureRandom.getInstanceStrong();
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
}
@RegisterExtension
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
.tableName(REDEEMED_RECEIPTS_TABLE_NAME)
.hashKey(RedeemedReceiptsManager.KEY_SERIAL)
.attributeDefinition(AttributeDefinition.builder()
.attributeName(RedeemedReceiptsManager.KEY_SERIAL)
.attributeType(ScalarAttributeType.B)
.build())
.build();
Clock clock;
ReceiptSerial receiptSerial;
RedeemedReceiptsManager redeemedReceiptsManager;
@BeforeEach
void beforeEach() throws InvalidInputException {
clock = mock(Clock.class);
when(clock.millis()).thenReturn(NOW_EPOCH_SECONDS * 1000L);
when(clock.instant()).thenReturn(Instant.ofEpochSecond(NOW_EPOCH_SECONDS));
byte[] receiptSerialBytes = new byte[ReceiptSerial.SIZE];
SECURE_RANDOM.nextBytes(receiptSerialBytes);
receiptSerial = new ReceiptSerial(receiptSerialBytes);
redeemedReceiptsManager = new RedeemedReceiptsManager(
clock, REDEEMED_RECEIPTS_TABLE_NAME, dynamoDbExtension.getDynamoDbAsyncClient(), Duration.ofDays(90));
}
@Test
void testPut() throws ExecutionException, InterruptedException {
final long receiptExpiration = 42;
final long receiptLevel = 3;
CompletableFuture<Boolean> put;
// initial insert should return true
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID);
assertThat(put.get()).isTrue();
// subsequent attempted inserts with modified parameters should return false
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration + 1, receiptLevel, AuthHelper.VALID_UUID);
assertThat(put.get()).isFalse();
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel + 1, AuthHelper.VALID_UUID);
assertThat(put.get()).isFalse();
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID_TWO);
assertThat(put.get()).isFalse();
// repeated insert attempt of the original parameters should return true
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID);
assertThat(put.get()).isTrue();
}
}