diff --git a/pom.xml b/pom.xml
index 5a0ba7640..26dc9db32 100644
--- a/pom.xml
+++ b/pom.xml
@@ -235,6 +235,12 @@
commons-logging
1.2
+
+ org.ow2.asm
+ asm
+ 9.2
+ test
+
@@ -248,7 +254,7 @@
com.github.tomakehurst
wiremock-jre8
- 2.28.1
+ 2.31.0
test
diff --git a/service/config/sample.yml b/service/config/sample.yml
index c41fbc7ce..5fe26335b 100644
--- a/service/config/sample.yml
+++ b/service/config/sample.yml
@@ -98,6 +98,11 @@ deletedAccountsLockDynamoDb: # DynamoDb table configuration
region:
tableName:
+redeemedReceiptsDynamoDb: # DynamoDB table configuration
+ region:
+ tableName:
+ expirationTime: # ISO8601 Duration
+
migrationDeletedAccountsDynamoDb: # DynamoDB table configuration
region:
tableName:
@@ -266,3 +271,5 @@ badges:
category: other
badgeIdsEnabledForAll:
- TEST
+ receiptLevels:
+ '1': TEST
diff --git a/service/pom.xml b/service/pom.xml
index ea53fb4c9..6ffa1ca23 100644
--- a/service/pom.xml
+++ b/service/pom.xml
@@ -55,7 +55,7 @@
org.signal
zkgroup-java
- 0.8.1
+ 0.8.2
org.whispersystems
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
index 79c87e89f..9ae7a5dec 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
@@ -38,6 +38,7 @@ import org.whispersystems.textsecuregcm.configuration.PushConfiguration;
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration;
import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration;
import org.whispersystems.textsecuregcm.configuration.RecaptchaV2Configuration;
+import org.whispersystems.textsecuregcm.configuration.RedeemedReceiptsDynamoDbConfiguration;
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
import org.whispersystems.textsecuregcm.configuration.RedisConfiguration;
import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration;
@@ -154,6 +155,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private DynamoDbConfiguration deletedAccountsLockDynamoDb;
+ @Valid
+ @NotNull
+ @JsonProperty
+ private RedeemedReceiptsDynamoDbConfiguration redeemedReceiptsDynamoDb;
+
@Valid
@NotNull
@JsonProperty
@@ -392,6 +398,10 @@ public class WhisperServerConfiguration extends Configuration {
return deletedAccountsLockDynamoDb;
}
+ public RedeemedReceiptsDynamoDbConfiguration getRedeemedReceiptsDynamoDbConfiguration() {
+ return redeemedReceiptsDynamoDb;
+ }
+
public DatabaseConfiguration getAbuseDatabaseConfiguration() {
return abuseDatabase;
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
index 4c25c5bd0..5d62486f5 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
@@ -56,6 +56,8 @@ import org.jdbi.v3.core.Jdbi;
import org.signal.zkgroup.ServerSecretParams;
import org.signal.zkgroup.auth.ServerZkAuthOperations;
import org.signal.zkgroup.profiles.ServerZkProfileOperations;
+import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
+import org.signal.zkgroup.receipts.ServerZkReceiptOperations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.dispatch.DispatchManager;
@@ -175,6 +177,7 @@ import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.PubSubManager;
import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb;
import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor;
+import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.RemoteConfigs;
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
@@ -208,6 +211,7 @@ import org.whispersystems.websocket.setup.WebSocketEnvironment;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.s3.S3Client;
@@ -286,8 +290,7 @@ public class WhisperServerService extends Application accountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(
accountAuthenticator).buildAuthFilter();
@@ -587,7 +600,8 @@ public class WhisperServerService extends Application badges;
private final List badgeIdsEnabledForAll;
+ private final Map receiptLevels;
@JsonCreator
public BadgesConfiguration(
@JsonProperty("badges") @JsonSetter(nulls = Nulls.AS_EMPTY) final List badges,
- @JsonProperty("badgeIdsEnabledForAll") @JsonSetter(nulls = Nulls.AS_EMPTY) final List badgeIdsEnabledForAll) {
+ @JsonProperty("badgeIdsEnabledForAll") @JsonSetter(nulls = Nulls.AS_EMPTY) final List badgeIdsEnabledForAll,
+ @JsonProperty("receiptLevels") @JsonSetter(nulls = Nulls.AS_EMPTY) final Map receiptLevels) {
this.badges = Objects.requireNonNull(badges);
this.badgeIdsEnabledForAll = Objects.requireNonNull(badgeIdsEnabledForAll);
+ this.receiptLevels = Objects.requireNonNull(receiptLevels);
}
@Valid
@@ -37,4 +45,17 @@ public class BadgesConfiguration {
public List getBadgeIdsEnabledForAll() {
return badgeIdsEnabledForAll;
}
+
+ @Valid
+ @NotNull
+ public Map getReceiptLevels() {
+ return receiptLevels;
+ }
+
+ @JsonIgnore
+ @ValidationMethod(message = "contains receipt level mappings that are not configured badges")
+ public boolean isAllReceiptLevelsConfigured() {
+ final Set badgeNames = badges.stream().map(BadgeConfiguration::getId).collect(Collectors.toSet());
+ return badgeNames.containsAll(receiptLevels.values());
+ }
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedeemedReceiptsDynamoDbConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedeemedReceiptsDynamoDbConfiguration.java
new file mode 100644
index 000000000..972297ff7
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedeemedReceiptsDynamoDbConfiguration.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2021 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.configuration;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.Duration;
+import javax.validation.constraints.NotNull;
+
+public class RedeemedReceiptsDynamoDbConfiguration extends DynamoDbConfiguration {
+
+ private Duration expirationTime;
+
+ @NotNull
+ @JsonProperty
+ public Duration getExpirationTime() {
+ return expirationTime;
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java
index 874c99413..e8f5334f3 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java
@@ -18,13 +18,22 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
+import java.time.Clock;
import java.time.Duration;
+import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
+import java.util.concurrent.ForkJoinPool;
+import java.util.concurrent.ForkJoinPool.ManagedBlocker;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
@@ -32,28 +41,64 @@ import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+import org.signal.zkgroup.InvalidInputException;
+import org.signal.zkgroup.VerificationFailedException;
+import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
+import org.signal.zkgroup.receipts.ReceiptSerial;
+import org.signal.zkgroup.receipts.ServerZkReceiptOperations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
+import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.DonationConfiguration;
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationRequest;
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationResponse;
+import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
import org.whispersystems.textsecuregcm.http.FormDataBodyPublisher;
+import org.whispersystems.textsecuregcm.storage.Account;
+import org.whispersystems.textsecuregcm.storage.AccountBadge;
+import org.whispersystems.textsecuregcm.storage.AccountsManager;
+import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.util.SystemMapper;
@Path("/v1/donation")
public class DonationController {
+ public interface ReceiptCredentialPresentationFactory {
+ ReceiptCredentialPresentation build(byte[] bytes) throws InvalidInputException;
+ }
+
private final Logger logger = LoggerFactory.getLogger(DonationController.class);
+ private final Clock clock;
+ private final ServerZkReceiptOperations serverZkReceiptOperations;
+ private final RedeemedReceiptsManager redeemedReceiptsManager;
+ private final AccountsManager accountsManager;
+ private final BadgesConfiguration badgesConfiguration;
+ private final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
private final URI uri;
private final String apiKey;
private final String description;
private final Set supportedCurrencies;
private final FaultTolerantHttpClient httpClient;
- public DonationController(final Executor executor, final DonationConfiguration configuration) {
+ public DonationController(
+ @Nonnull final Clock clock,
+ @Nonnull final ServerZkReceiptOperations serverZkReceiptOperations,
+ @Nonnull final RedeemedReceiptsManager redeemedReceiptsManager,
+ @Nonnull final AccountsManager accountsManager,
+ @Nonnull final BadgesConfiguration badgesConfiguration,
+ @Nonnull final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory,
+ @Nonnull final Executor httpClientExecutor,
+ @Nonnull final DonationConfiguration configuration) {
+ this.clock = Objects.requireNonNull(clock);
+ this.serverZkReceiptOperations = Objects.requireNonNull(serverZkReceiptOperations);
+ this.redeemedReceiptsManager = Objects.requireNonNull(redeemedReceiptsManager);
+ this.accountsManager = Objects.requireNonNull(accountsManager);
+ this.badgesConfiguration = Objects.requireNonNull(badgesConfiguration);
+ this.receiptCredentialPresentationFactory = Objects.requireNonNull(receiptCredentialPresentationFactory);
this.uri = URI.create(configuration.getUri());
this.apiKey = configuration.getApiKey();
this.description = configuration.getDescription();
@@ -64,12 +109,82 @@ public class DonationController {
.withVersion(Version.HTTP_2)
.withConnectTimeout(Duration.ofSeconds(10))
.withRedirect(Redirect.NEVER)
- .withExecutor(executor)
+ .withExecutor(Objects.requireNonNull(httpClientExecutor))
.withName("donation")
.withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_3)
.build();
}
+ @Timed
+ @POST
+ @Path("/redeem-receipt")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN})
+ public CompletionStage redeemReceipt(
+ @Auth final AuthenticatedAccount auth,
+ @Valid final RedeemReceiptRequest request) {
+ return CompletableFuture.supplyAsync(() -> {
+ ReceiptCredentialPresentation receiptCredentialPresentation;
+ try {
+ receiptCredentialPresentation = receiptCredentialPresentationFactory.build(
+ request.getReceiptCredentialPresentation());
+ } catch (InvalidInputException e) {
+ return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST).entity("invalid receipt credential presentation").type(MediaType.TEXT_PLAIN_TYPE).build());
+ }
+ try {
+ serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);
+ } catch (VerificationFailedException e) {
+ return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST).entity("receipt credential presentation verification failed").type(MediaType.TEXT_PLAIN_TYPE).build());
+ }
+
+ final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();
+ final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());
+ final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
+ final String badgeId = badgesConfiguration.getReceiptLevels().get(receiptLevel);
+ if (badgeId == null) {
+ return CompletableFuture.completedFuture(Response.serverError().entity("server does not recognize the requested receipt level").type(MediaType.TEXT_PLAIN_TYPE).build());
+ }
+ final CompletionStage putStage = redeemedReceiptsManager.put(
+ receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.getAccount().getUuid());
+ return putStage.thenApplyAsync(receiptMatched -> {
+ if (!receiptMatched) {
+ return Response.status(Status.BAD_REQUEST).entity("receipt serial is already redeemed").type(MediaType.TEXT_PLAIN_TYPE).build();
+ }
+
+ try {
+ ForkJoinPool.managedBlock(new ManagedBlocker() {
+ boolean done = false;
+
+ @Override
+ public boolean block() {
+ final Optional optionalAccount = accountsManager.get(auth.getAccount().getUuid());
+ optionalAccount.ifPresent(account -> {
+ accountsManager.update(account, a -> {
+ a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible()));
+ if (request.isPrimary()) {
+ a.makeBadgePrimaryIfExists(clock, badgeId);
+ }
+ });
+ });
+ done = true;
+ return true;
+ }
+
+ @Override
+ public boolean isReleasable() {
+ return done;
+ }
+ });
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return Response.serverError().build();
+ }
+
+ return Response.ok().build();
+ });
+ }).thenCompose(Function.identity());
+ }
+
@Timed
@POST
@Path("/authorize-apple-pay")
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeliveryCertificate.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeliveryCertificate.java
index 3696dabe1..49dd46503 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeliveryCertificate.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeliveryCertificate.java
@@ -5,48 +5,20 @@
package org.whispersystems.textsecuregcm.entities;
+import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
-import com.fasterxml.jackson.core.JsonGenerator;
-import com.fasterxml.jackson.core.JsonParser;
-import com.fasterxml.jackson.databind.DeserializationContext;
-import com.fasterxml.jackson.databind.JsonDeserializer;
-import com.fasterxml.jackson.databind.JsonSerializer;
-import com.fasterxml.jackson.databind.SerializerProvider;
-import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
-import com.fasterxml.jackson.databind.annotation.JsonSerialize;
-import com.google.common.annotations.VisibleForTesting;
-import java.io.IOException;
-import java.util.Base64;
public class DeliveryCertificate {
- @JsonProperty
- @JsonSerialize(using = ByteArraySerializer.class)
- @JsonDeserialize(using = ByteArrayDeserializer.class)
- private byte[] certificate;
+ private final byte[] certificate;
- public DeliveryCertificate(byte[] certificate) {
+ @JsonCreator
+ public DeliveryCertificate(
+ @JsonProperty("certificate") byte[] certificate) {
this.certificate = certificate;
}
- public DeliveryCertificate() {}
-
- @VisibleForTesting
public byte[] getCertificate() {
return certificate;
}
-
- public static class ByteArraySerializer extends JsonSerializer {
- @Override
- public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
- jsonGenerator.writeString(Base64.getEncoder().encodeToString(bytes));
- }
- }
-
- public static class ByteArrayDeserializer extends JsonDeserializer {
- @Override
- public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
- return Base64.getDecoder().decode(jsonParser.getValueAsString());
- }
- }
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java
index b000976bc..0bb71bece 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java
@@ -1,32 +1,21 @@
/*
- * Copyright 2013-2020 Signal Messenger, LLC
+ * Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
+import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
-import com.fasterxml.jackson.core.JsonGenerator;
-import com.fasterxml.jackson.core.JsonParser;
-import com.fasterxml.jackson.databind.DeserializationContext;
-import com.fasterxml.jackson.databind.JsonDeserializer;
-import com.fasterxml.jackson.databind.JsonSerializer;
-import com.fasterxml.jackson.databind.SerializerProvider;
-import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
-import com.fasterxml.jackson.databind.annotation.JsonSerialize;
-
-import java.io.IOException;
-import java.util.Base64;
import java.util.List;
public class GroupCredentials {
- @JsonProperty
- private List credentials;
+ private final List credentials;
- public GroupCredentials() {}
-
- public GroupCredentials(List credentials) {
+ @JsonCreator
+ public GroupCredentials(
+ @JsonProperty("credentials") List credentials) {
this.credentials = credentials;
}
@@ -36,18 +25,14 @@ public class GroupCredentials {
public static class GroupCredential {
- @JsonProperty
- @JsonSerialize(using = ByteArraySerializer.class)
- @JsonDeserialize(using = ByteArrayDeserializer.class)
- private byte[] credential;
+ private final byte[] credential;
+ private final int redemptionTime;
- @JsonProperty
- private int redemptionTime;
-
- public GroupCredential() {}
-
- public GroupCredential(byte[] credential, int redemptionTime) {
- this.credential = credential;
+ @JsonCreator
+ public GroupCredential(
+ @JsonProperty("credential") byte[] credential,
+ @JsonProperty("redemptionTime") int redemptionTime) {
+ this.credential = credential;
this.redemptionTime = redemptionTime;
}
@@ -59,19 +44,4 @@ public class GroupCredentials {
return redemptionTime;
}
}
-
- public static class ByteArraySerializer extends JsonSerializer {
- @Override
- public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
- jsonGenerator.writeString(Base64.getEncoder().encodeToString(bytes));
- }
- }
-
- public static class ByteArrayDeserializer extends JsonDeserializer {
- @Override
- public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
- return Base64.getDecoder().decode(jsonParser.getValueAsString());
- }
- }
-
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RedeemReceiptRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RedeemReceiptRequest.java
new file mode 100644
index 000000000..ab72a31ab
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RedeemReceiptRequest.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2021 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.entities;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import javax.validation.constraints.NotEmpty;
+import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
+import org.whispersystems.textsecuregcm.util.ExactlySize;
+
+public class RedeemReceiptRequest {
+
+ private final byte[] receiptCredentialPresentation;
+ private final boolean visible;
+ private final boolean primary;
+
+ @JsonCreator
+ public RedeemReceiptRequest(
+ @JsonProperty("receiptCredentialPresentation") byte[] receiptCredentialPresentation,
+ @JsonProperty("visible") boolean visible,
+ @JsonProperty("primary") boolean primary) {
+ this.receiptCredentialPresentation = receiptCredentialPresentation;
+ this.visible = visible;
+ this.primary = primary;
+ }
+
+ @NotEmpty
+ @ExactlySize({ReceiptCredentialPresentation.SIZE})
+ public byte[] getReceiptCredentialPresentation() {
+ return receiptCredentialPresentation;
+ }
+
+ public boolean isVisible() {
+ return visible;
+ }
+
+ public boolean isPrimary() {
+ return primary;
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java
index bc82af8a2..64dba40a6 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java
@@ -353,6 +353,30 @@ public class Account {
purgeStaleBadges(clock);
}
+ public void makeBadgePrimaryIfExists(Clock clock, String badgeId) {
+ requireNotStale();
+
+ // early exit if it's already the first item in the list
+ if (!badges.isEmpty() && Objects.equals(badges.get(0).getId(), badgeId)) {
+ purgeStaleBadges(clock);
+ return;
+ }
+
+ int indexOfBadge = -1;
+ for (int i = 1; i < badges.size(); i++) {
+ if (Objects.equals(badgeId, badges.get(i).getId())) {
+ indexOfBadge = i;
+ break;
+ }
+ }
+
+ if (indexOfBadge != -1) {
+ badges.add(0, badges.remove(indexOfBadge));
+ }
+
+ purgeStaleBadges(clock);
+ }
+
public void removeBadge(Clock clock, String id) {
requireNotStale();
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java
new file mode 100644
index 000000000..e58ff0337
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2021 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.storage;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import javax.annotation.Nonnull;
+import org.signal.zkgroup.receipts.ReceiptSerial;
+import org.whispersystems.textsecuregcm.util.AttributeValues;
+import org.whispersystems.textsecuregcm.util.UUIDUtil;
+import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
+import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
+
+public class RedeemedReceiptsManager {
+
+ public static final String KEY_SERIAL = "S";
+ public static final String KEY_TTL = "E";
+ public static final String KEY_RECEIPT_EXPIRATION = "G";
+ public static final String KEY_RECEIPT_LEVEL = "L";
+ public static final String KEY_ACCOUNT_UUID = "U";
+ public static final String KEY_REDEMPTION_TIME = "R";
+
+ private final Clock clock;
+ private final String table;
+ private final DynamoDbAsyncClient client;
+ private final Duration expirationTime;
+
+ public RedeemedReceiptsManager(
+ @Nonnull final Clock clock,
+ @Nonnull final String table,
+ @Nonnull final DynamoDbAsyncClient client,
+ @Nonnull final Duration expirationTime) {
+ this.clock = Objects.requireNonNull(clock);
+ this.table = Objects.requireNonNull(table);
+ this.client = Objects.requireNonNull(client);
+ this.expirationTime = Objects.requireNonNull(expirationTime);
+ }
+
+ /**
+ * Returns true either if it's able to insert a new redeemed receipt entry with the {@code receiptExpiration}, {@code
+ * receiptLevel}, and {@code accountUuid} provided or if an existing entry already exists with the same values thereby
+ * allowing idempotent request processing.
+ */
+ public CompletableFuture put(
+ @Nonnull final ReceiptSerial receiptSerial,
+ final long receiptExpiration,
+ final long receiptLevel,
+ @Nonnull final UUID accountUuid) {
+
+ // fail early if given bad inputs
+ Objects.requireNonNull(receiptSerial);
+ Objects.requireNonNull(accountUuid);
+
+ final Instant now = clock.instant();
+ final Instant rowExpiration = now.plus(expirationTime);
+ final AttributeValue serialAttributeValue = AttributeValues.b(receiptSerial.serialize());
+
+ final UpdateItemRequest updateItemRequest = UpdateItemRequest.builder()
+ .tableName(table)
+ .key(Map.of(KEY_SERIAL, serialAttributeValue))
+ .returnValues(ReturnValue.ALL_NEW)
+ .updateExpression("SET #ttl = if_not_exists(#ttl, :ttl), "
+ + "#receipt_expiration = if_not_exists(#receipt_expiration, :receipt_expiration), "
+ + "#receipt_level = if_not_exists(#receipt_level, :receipt_level), "
+ + "#account_uuid = if_not_exists(#account_uuid, :account_uuid), "
+ + "#redemption_time = if_not_exists(#redemption_time, :redemption_time)")
+ .expressionAttributeNames(Map.of(
+ "#ttl", KEY_TTL,
+ "#receipt_expiration", KEY_RECEIPT_EXPIRATION,
+ "#receipt_level", KEY_RECEIPT_LEVEL,
+ "#account_uuid", KEY_ACCOUNT_UUID,
+ "#redemption_time", KEY_REDEMPTION_TIME))
+ .expressionAttributeValues(Map.of(
+ ":ttl", AttributeValues.n(rowExpiration.getEpochSecond()),
+ ":receipt_expiration", AttributeValues.n(receiptExpiration),
+ ":receipt_level", AttributeValues.n(receiptLevel),
+ ":account_uuid", AttributeValues.b(accountUuid),
+ ":redemption_time", AttributeValues.n(now.getEpochSecond())))
+ .build();
+ return client.updateItem(updateItemRequest).thenApply(updateItemResponse -> {
+ final Map attributes = updateItemResponse.attributes();
+ final long ddbReceiptExpiration = Long.parseLong(attributes.get(KEY_RECEIPT_EXPIRATION).n());
+ final long ddbReceiptLevel = Long.parseLong(attributes.get(KEY_RECEIPT_LEVEL).n());
+ final UUID ddbAccountUuid = UUIDUtil.fromByteBuffer(attributes.get(KEY_ACCOUNT_UUID).b().asByteBuffer());
+ return ddbReceiptExpiration == receiptExpiration && ddbReceiptLevel == receiptLevel &&
+ Objects.equals(ddbAccountUuid, accountUuid);
+ });
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/AttributeValues.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/AttributeValues.java
index 20464bd7c..de77e859a 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/util/AttributeValues.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/AttributeValues.java
@@ -15,6 +15,26 @@ import java.util.UUID;
/** AwsAV provides static helper methods for working with AWS AttributeValues. */
public class AttributeValues {
+ // Clear-type methods
+
+ public static AttributeValue b(byte[] value) {
+ return AttributeValue.builder().b(SdkBytes.fromByteArray(value)).build();
+ }
+
+ public static AttributeValue b(ByteBuffer value) {
+ return AttributeValue.builder().b(SdkBytes.fromByteBuffer(value)).build();
+ }
+
+ public static AttributeValue b(UUID value) {
+ return b(UUIDUtil.toByteBuffer(value));
+ }
+
+ public static AttributeValue n(long value) {
+ return AttributeValue.builder().n(String.valueOf(value)).build();
+ }
+
+ // More opinionated methods
+
public static AttributeValue fromString(String value) {
return AttributeValue.builder().s(value).build();
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/DynamoDbFromConfig.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/DynamoDbFromConfig.java
index 738252b5c..1e30d05dc 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/util/DynamoDbFromConfig.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/DynamoDbFromConfig.java
@@ -2,22 +2,21 @@ package org.whispersystems.textsecuregcm.util;
import org.whispersystems.textsecuregcm.configuration.DynamoDbConfiguration;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
-import software.amazon.awssdk.core.client.config.ClientAsyncConfiguration;
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
-import software.amazon.awssdk.core.client.config.SdkAdvancedAsyncClientOption;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClientBuilder;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
-import java.util.concurrent.Executor;
public class DynamoDbFromConfig {
+
private static ClientOverrideConfiguration clientOverrideConfiguration(DynamoDbConfiguration config) {
return ClientOverrideConfiguration.builder()
.apiCallTimeout(config.getClientExecutionTimeout())
.apiCallAttemptTimeout(config.getClientRequestTimeout())
.build();
}
+
public static DynamoDbClient client(DynamoDbConfiguration config, AwsCredentialsProvider credentialsProvider) {
return DynamoDbClient.builder()
.region(Region.of(config.getRegion()))
@@ -25,17 +24,13 @@ public class DynamoDbFromConfig {
.overrideConfiguration(clientOverrideConfiguration(config))
.build();
}
- public static DynamoDbAsyncClient asyncClient(DynamoDbConfiguration config, AwsCredentialsProvider credentialsProvider, Executor executor) {
+
+ public static DynamoDbAsyncClient asyncClient(
+ DynamoDbConfiguration config, AwsCredentialsProvider credentialsProvider) {
DynamoDbAsyncClientBuilder builder = DynamoDbAsyncClient.builder()
.region(Region.of(config.getRegion()))
.credentialsProvider(credentialsProvider)
.overrideConfiguration(clientOverrideConfiguration(config));
- if (executor != null) {
- builder.asyncConfiguration(ClientAsyncConfiguration.builder()
- .advancedOption(SdkAdvancedAsyncClientOption.FUTURE_COMPLETION_EXECUTOR,
- executor)
- .build());
- }
return builder.build();
}
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySize.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySize.java
index 21f93325d..6c0145839 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySize.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySize.java
@@ -5,19 +5,24 @@
package org.whispersystems.textsecuregcm.util;
-import javax.validation.Constraint;
-import javax.validation.Payload;
-import java.lang.annotation.Documented;
-import java.lang.annotation.Retention;
-import java.lang.annotation.Target;
-
-import static java.lang.annotation.ElementType.*;
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+import javax.validation.Constraint;
+import javax.validation.Payload;
+
@Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE })
@Retention(RUNTIME)
-@Constraint(validatedBy = ExactlySizeValidator.class)
+@Constraint(validatedBy = {
+ ExactlySizeValidatorForString.class,
+ ExactlySizeValidatorForArraysOfByte.class,
+})
@Documented
public @interface ExactlySize {
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidator.java
index 8a828c07e..a66cf939f 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidator.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidator.java
@@ -1,34 +1,29 @@
/*
- * Copyright 2013-2020 Signal Messenger, LLC
+ * Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.util;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Collectors;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
+public abstract class ExactlySizeValidator implements ConstraintValidator {
-public class ExactlySizeValidator implements ConstraintValidator {
-
- private int[] permittedSizes;
+ private Set permittedSizes;
@Override
- public void initialize(ExactlySize exactlySize) {
- this.permittedSizes = exactlySize.value();
+ public void initialize(ExactlySize annotation) {
+ permittedSizes = Arrays.stream(annotation.value()).boxed().collect(Collectors.toSet());
}
@Override
- public boolean isValid(String object, ConstraintValidatorContext constraintContext) {
- int objectLength;
-
- if (object == null) objectLength = 0;
- else objectLength = object.length();
-
- for (int permittedSize : permittedSizes) {
- if (permittedSize == objectLength) return true;
- }
-
- return false;
+ public boolean isValid(T value, ConstraintValidatorContext context) {
+ return permittedSizes.contains(size(value));
}
+
+ protected abstract int size(T value);
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForArraysOfByte.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForArraysOfByte.java
new file mode 100644
index 000000000..358eaf65a
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForArraysOfByte.java
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2021 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.util;
+
+public class ExactlySizeValidatorForArraysOfByte extends ExactlySizeValidator {
+
+ @Override
+ protected int size(final byte[] value) {
+ return value == null ? 0 : value.length;
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForString.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForString.java
new file mode 100644
index 000000000..9d77641cd
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForString.java
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2013-2020 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.util;
+
+
+public class ExactlySizeValidatorForString extends ExactlySizeValidator {
+
+ @Override
+ protected int size(final String value) {
+ return value == null ? 0 : value.length();
+ }
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java
index 201da05a6..f3df7c867 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java
@@ -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) {
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java
index 0f0fd3ed9..bf77103ce 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java
@@ -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");
+ }
}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java
index 7f5cf084c..7f98df85d 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java
@@ -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,
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RedeemedReceiptsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RedeemedReceiptsManagerTest.java
new file mode 100644
index 000000000..cb66307d7
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RedeemedReceiptsManagerTest.java
@@ -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 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();
+ }
+}
diff --git a/service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 000000000..1f0955d45
--- /dev/null
+++ b/service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline