Update `SubscriptionManager` to store processor+customerId in a single attribute and a map
- add `type` query parameter to `/v1/subscription/{subscriberId}/create_payment_method`
This commit is contained in:
parent
308437ec93
commit
6341770768
|
@ -205,7 +205,7 @@ import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
|||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||
import org.whispersystems.textsecuregcm.stripe.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||
import org.whispersystems.textsecuregcm.util.HostnameUtil;
|
||||
|
|
|
@ -48,6 +48,7 @@ import javax.validation.constraints.NotNull;
|
|||
import javax.ws.rs.BadRequestException;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.DELETE;
|
||||
import javax.ws.rs.DefaultValue;
|
||||
import javax.ws.rs.ForbiddenException;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.InternalServerErrorException;
|
||||
|
@ -58,6 +59,7 @@ import javax.ws.rs.Path;
|
|||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.ProcessingException;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.container.ContainerRequestContext;
|
||||
import javax.ws.rs.core.Context;
|
||||
|
@ -87,7 +89,11 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
|||
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult;
|
||||
import org.whispersystems.textsecuregcm.stripe.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
|
||||
@Path("/v1/subscription")
|
||||
|
@ -179,15 +185,13 @@ public class SubscriptionController {
|
|||
throw new ForbiddenException("subscriberId mismatch");
|
||||
} else if (getResult == GetResult.NOT_STORED) {
|
||||
// create a customer and write it to ddb
|
||||
return stripeManager.createCustomer(requestData.subscriberUser).thenCompose(
|
||||
customer -> subscriptionManager.create(
|
||||
requestData.subscriberUser, requestData.hmac, customer.getId(), requestData.now)
|
||||
.thenApply(updatedRecord -> {
|
||||
if (updatedRecord == null) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return updatedRecord;
|
||||
}));
|
||||
return subscriptionManager.create(requestData.subscriberUser, requestData.hmac, requestData.now)
|
||||
.thenApply(updatedRecord -> {
|
||||
if (updatedRecord == null) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return updatedRecord;
|
||||
});
|
||||
} else {
|
||||
// already exists so just touch access time and return
|
||||
return subscriptionManager.accessedAt(requestData.subscriberUser, requestData.now)
|
||||
|
@ -197,20 +201,8 @@ public class SubscriptionController {
|
|||
.thenApply(record -> Response.ok().build());
|
||||
}
|
||||
|
||||
public static class CreatePaymentMethodResponse {
|
||||
record CreatePaymentMethodResponse(String clientSecret, SubscriptionProcessor processor) {
|
||||
|
||||
private final String clientSecret;
|
||||
|
||||
@JsonCreator
|
||||
public CreatePaymentMethodResponse(
|
||||
@JsonProperty("clientSecret") String clientSecret) {
|
||||
this.clientSecret = clientSecret;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public String getClientSecret() {
|
||||
return clientSecret;
|
||||
}
|
||||
}
|
||||
|
||||
@Timed
|
||||
|
@ -220,12 +212,39 @@ public class SubscriptionController {
|
|||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createPaymentMethod(
|
||||
@Auth Optional<AuthenticatedAccount> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId) {
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@QueryParam("type") @DefaultValue("CARD") PaymentMethod paymentMethodType) {
|
||||
|
||||
RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock);
|
||||
|
||||
final SubscriptionProcessorManager subscriptionProcessorManager = getManagerForPaymentMethod(paymentMethodType);
|
||||
|
||||
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
||||
.thenApply(this::requireRecordFromGetResult)
|
||||
.thenCompose(record -> stripeManager.createSetupIntent(record.customerId))
|
||||
.thenApply(setupIntent -> Response.ok(new CreatePaymentMethodResponse(setupIntent.getClientSecret())).build());
|
||||
.thenCompose(record -> {
|
||||
final CompletableFuture<SubscriptionManager.Record> updatedRecordFuture;
|
||||
if (record.customerId == null) {
|
||||
updatedRecordFuture = subscriptionProcessorManager.createCustomer(requestData.subscriberUser)
|
||||
.thenApply(ProcessorCustomer::customerId)
|
||||
.thenCompose(customerId -> subscriptionManager.updateProcessorAndCustomerId(record,
|
||||
new ProcessorCustomer(customerId,
|
||||
subscriptionProcessorManager.getProcessor()), Instant.now()));
|
||||
} else {
|
||||
updatedRecordFuture = CompletableFuture.completedFuture(record);
|
||||
}
|
||||
|
||||
return updatedRecordFuture.thenCompose(
|
||||
updatedRecord -> subscriptionProcessorManager.createPaymentMethodSetupToken(updatedRecord.customerId));
|
||||
})
|
||||
.thenApply(
|
||||
token -> Response.ok(new CreatePaymentMethodResponse(token, subscriptionProcessorManager.getProcessor()))
|
||||
.build());
|
||||
}
|
||||
|
||||
private SubscriptionProcessorManager getManagerForPaymentMethod(PaymentMethod paymentMethod) {
|
||||
return switch (paymentMethod) {
|
||||
case CARD -> stripeManager;
|
||||
};
|
||||
}
|
||||
|
||||
@Timed
|
||||
|
|
|
@ -6,23 +6,32 @@
|
|||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.b;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.m;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.n;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.s;
|
||||
|
||||
import com.google.common.base.Throwables;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
|
||||
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
|
||||
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
|
||||
|
@ -35,8 +44,11 @@ public class SubscriptionManager {
|
|||
|
||||
public static final String KEY_USER = "U"; // B (Hash Key)
|
||||
public static final String KEY_PASSWORD = "P"; // B
|
||||
@Deprecated
|
||||
public static final String KEY_CUSTOMER_ID = "C"; // S (GSI Hash Key of `c_to_u` index)
|
||||
public static final String KEY_PROCESSOR_ID_CUSTOMER_ID = "PC"; // B (GSI Hash Key of `pc_to_u` index)
|
||||
public static final String KEY_CREATED_AT = "R"; // N
|
||||
public static final String KEY_PROCESSOR_CUSTOMER_IDS_MAP = "PCI"; // M
|
||||
public static final String KEY_SUBSCRIPTION_ID = "S"; // S
|
||||
public static final String KEY_SUBSCRIPTION_CREATED_AT = "T"; // N
|
||||
public static final String KEY_SUBSCRIPTION_LEVEL = "L";
|
||||
|
@ -51,8 +63,10 @@ public class SubscriptionManager {
|
|||
|
||||
public final byte[] user;
|
||||
public final byte[] password;
|
||||
public final String customerId;
|
||||
public final Instant createdAt;
|
||||
public @Nullable String customerId;
|
||||
public @Nullable SubscriptionProcessor processor;
|
||||
public Map<SubscriptionProcessor, String> processorsToCustomerIds;
|
||||
public String subscriptionId;
|
||||
public Instant subscriptionCreatedAt;
|
||||
public Long subscriptionLevel;
|
||||
|
@ -61,10 +75,9 @@ public class SubscriptionManager {
|
|||
public Instant canceledAt;
|
||||
public Instant currentPeriodEndsAt;
|
||||
|
||||
private Record(byte[] user, byte[] password, String customerId, Instant createdAt) {
|
||||
private Record(byte[] user, byte[] password, Instant createdAt) {
|
||||
this.user = checkUserLength(user);
|
||||
this.password = Objects.requireNonNull(password);
|
||||
this.customerId = Objects.requireNonNull(customerId);
|
||||
this.createdAt = Objects.requireNonNull(createdAt);
|
||||
}
|
||||
|
||||
|
@ -72,8 +85,17 @@ public class SubscriptionManager {
|
|||
Record self = new Record(
|
||||
user,
|
||||
item.get(KEY_PASSWORD).b().asByteArray(),
|
||||
item.get(KEY_CUSTOMER_ID).s(),
|
||||
getInstant(item, KEY_CREATED_AT));
|
||||
|
||||
final Pair<SubscriptionProcessor, String> processorCustomerId = getProcessorAndCustomer(item);
|
||||
if (processorCustomerId != null) {
|
||||
self.customerId = processorCustomerId.second();
|
||||
self.processor = processorCustomerId.first();
|
||||
} else {
|
||||
// Until all existing data is migrated to KEY_PROCESSOR_ID_CUSTOMER_ID, fall back to KEY_CUSTOMER_ID
|
||||
self.customerId = getString(item, KEY_CUSTOMER_ID);
|
||||
}
|
||||
self.processorsToCustomerIds = getProcessorsToCustomerIds(item);
|
||||
self.subscriptionId = getString(item, KEY_SUBSCRIPTION_ID);
|
||||
self.subscriptionCreatedAt = getInstant(item, KEY_SUBSCRIPTION_CREATED_AT);
|
||||
self.subscriptionLevel = getLong(item, KEY_SUBSCRIPTION_LEVEL);
|
||||
|
@ -84,8 +106,45 @@ public class SubscriptionManager {
|
|||
return self;
|
||||
}
|
||||
|
||||
public Map<String, AttributeValue> asKey() {
|
||||
return Map.of(KEY_USER, b(user));
|
||||
private static Map<SubscriptionProcessor, String> getProcessorsToCustomerIds(Map<String, AttributeValue> item) {
|
||||
final AttributeValue attributeValue = item.get(KEY_PROCESSOR_CUSTOMER_IDS_MAP);
|
||||
final Map<String, AttributeValue> attribute =
|
||||
attributeValue == null ? Collections.emptyMap() : attributeValue.m();
|
||||
|
||||
final Map<SubscriptionProcessor, String> processorsToCustomerIds = new HashMap<>();
|
||||
attribute.forEach((processorName, customerId) ->
|
||||
processorsToCustomerIds.put(SubscriptionProcessor.valueOf(processorName), customerId.s()));
|
||||
|
||||
return processorsToCustomerIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the active processor and customer from a single attribute value in the given item.
|
||||
* <p>
|
||||
* Until existing data is migrated, this may return {@code null}.
|
||||
*/
|
||||
@Nullable
|
||||
private static Pair<SubscriptionProcessor, String> getProcessorAndCustomer(Map<String, AttributeValue> item) {
|
||||
|
||||
final AttributeValue attributeValue = item.get(KEY_PROCESSOR_ID_CUSTOMER_ID);
|
||||
|
||||
if (attributeValue == null) {
|
||||
// temporarily allow null values
|
||||
return null;
|
||||
}
|
||||
|
||||
final byte[] processorAndCustomerId = attributeValue.b().asByteArray();
|
||||
final byte processorId = processorAndCustomerId[0];
|
||||
|
||||
final SubscriptionProcessor processor = SubscriptionProcessor.forId(processorId);
|
||||
if (processor == null) {
|
||||
throw new IllegalStateException("unknown processor id: " + processorId);
|
||||
}
|
||||
|
||||
final String customerId = new String(processorAndCustomerId, 1, processorAndCustomerId.length - 1,
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
return new Pair<>(processor, customerId);
|
||||
}
|
||||
|
||||
private static String getString(Map<String, AttributeValue> item, String key) {
|
||||
|
@ -181,14 +240,7 @@ public class SubscriptionManager {
|
|||
* Looks up a record with the given {@code user} and validates the {@code hmac} before returning it.
|
||||
*/
|
||||
public CompletableFuture<GetResult> get(byte[] user, byte[] hmac) {
|
||||
checkUserLength(user);
|
||||
|
||||
GetItemRequest request = GetItemRequest.builder()
|
||||
.consistentRead(Boolean.TRUE)
|
||||
.tableName(table)
|
||||
.key(Map.of(KEY_USER, b(user)))
|
||||
.build();
|
||||
return client.getItem(request).thenApply(getItemResponse -> {
|
||||
return getUser(user).thenApply(getItemResponse -> {
|
||||
if (!getItemResponse.hasItem()) {
|
||||
return GetResult.NOT_STORED;
|
||||
}
|
||||
|
@ -201,7 +253,19 @@ public class SubscriptionManager {
|
|||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Record> create(byte[] user, byte[] password, String customerId, Instant createdAt) {
|
||||
private CompletableFuture<GetItemResponse> getUser(byte[] user) {
|
||||
checkUserLength(user);
|
||||
|
||||
GetItemRequest request = GetItemRequest.builder()
|
||||
.consistentRead(Boolean.TRUE)
|
||||
.tableName(table)
|
||||
.key(Map.of(KEY_USER, b(user)))
|
||||
.build();
|
||||
|
||||
return client.getItem(request);
|
||||
}
|
||||
|
||||
public CompletableFuture<Record> create(byte[] user, byte[] password, Instant createdAt) {
|
||||
checkUserLength(user);
|
||||
|
||||
UpdateItemRequest request = UpdateItemRequest.builder()
|
||||
|
@ -211,20 +275,23 @@ public class SubscriptionManager {
|
|||
.conditionExpression("attribute_not_exists(#user) OR #password = :password")
|
||||
.updateExpression("SET "
|
||||
+ "#password = if_not_exists(#password, :password), "
|
||||
+ "#customer_id = if_not_exists(#customer_id, :customer_id), "
|
||||
+ "#created_at = if_not_exists(#created_at, :created_at), "
|
||||
+ "#accessed_at = if_not_exists(#accessed_at, :accessed_at)")
|
||||
+ "#accessed_at = if_not_exists(#accessed_at, :accessed_at), "
|
||||
+ "#processors_to_customer_ids = if_not_exists(#processors_to_customer_ids, :initial_empty_map)"
|
||||
)
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#user", KEY_USER,
|
||||
"#password", KEY_PASSWORD,
|
||||
"#customer_id", KEY_CUSTOMER_ID,
|
||||
"#created_at", KEY_CREATED_AT,
|
||||
"#accessed_at", KEY_ACCESSED_AT))
|
||||
"#accessed_at", KEY_ACCESSED_AT,
|
||||
"#processors_to_customer_ids", KEY_PROCESSOR_CUSTOMER_IDS_MAP)
|
||||
)
|
||||
.expressionAttributeValues(Map.of(
|
||||
":password", b(password),
|
||||
":customer_id", s(customerId),
|
||||
":created_at", n(createdAt.getEpochSecond()),
|
||||
":accessed_at", n(createdAt.getEpochSecond())))
|
||||
":accessed_at", n(createdAt.getEpochSecond()),
|
||||
":initial_empty_map", m(Map.of()))
|
||||
)
|
||||
.build();
|
||||
return client.updateItem(request).handle((updateItemResponse, throwable) -> {
|
||||
if (throwable != null) {
|
||||
|
@ -239,6 +306,76 @@ public class SubscriptionManager {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the active processor and customer ID for the given user record.
|
||||
*
|
||||
* @return the updated user record.
|
||||
*/
|
||||
public CompletableFuture<Record> updateProcessorAndCustomerId(Record userRecord,
|
||||
ProcessorCustomer activeProcessorCustomer, Instant updatedAt) {
|
||||
|
||||
// Don’t attempt to modify the existing map, since it may be immutable, and we also don’t want to have side effects
|
||||
final Map<SubscriptionProcessor, String> allProcessorsAndCustomerIds = new HashMap<>(
|
||||
userRecord.processorsToCustomerIds);
|
||||
allProcessorsAndCustomerIds.put(activeProcessorCustomer.processor(), activeProcessorCustomer.customerId());
|
||||
|
||||
UpdateItemRequest request = UpdateItemRequest.builder()
|
||||
.tableName(table)
|
||||
.key(Map.of(KEY_USER, b(userRecord.user)))
|
||||
.returnValues(ReturnValue.ALL_NEW)
|
||||
.conditionExpression(
|
||||
// there is no customer attribute yet
|
||||
"attribute_not_exists(#customer_id) " +
|
||||
// OR this record doesn't have the new processor+customer attributes yet
|
||||
"OR (#customer_id = :customer_id " +
|
||||
"AND attribute_not_exists(#processor_customer_id) " +
|
||||
// TODO once all records are guaranteed to have the map, we can do a more targeted update
|
||||
// "AND attribute_not_exists(#processors_to_customer_ids.#processor_name) " +
|
||||
"AND attribute_not_exists(#processors_to_customer_ids))"
|
||||
)
|
||||
.updateExpression("SET "
|
||||
+ "#customer_id = :customer_id, "
|
||||
+ "#processor_customer_id = :processor_customer_id, "
|
||||
// TODO once all records are guaranteed to have the map, we can do a more targeted update
|
||||
// + "#processors_to_customer_ids.#processor_name = :customer_id, "
|
||||
+ "#processors_to_customer_ids = :processors_and_customer_ids, "
|
||||
+ "#accessed_at = :accessed_at"
|
||||
)
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#accessed_at", KEY_ACCESSED_AT,
|
||||
"#customer_id", KEY_CUSTOMER_ID,
|
||||
"#processor_customer_id", KEY_PROCESSOR_ID_CUSTOMER_ID,
|
||||
// TODO "#processor_name", activeProcessor.name(),
|
||||
"#processors_to_customer_ids", KEY_PROCESSOR_CUSTOMER_IDS_MAP
|
||||
))
|
||||
.expressionAttributeValues(Map.of(
|
||||
":accessed_at", n(updatedAt.getEpochSecond()),
|
||||
":customer_id", s(activeProcessorCustomer.customerId()),
|
||||
":processor_customer_id", b(activeProcessorCustomer.toDynamoBytes()),
|
||||
":processors_and_customer_ids", m(createProcessorsToCustomerIdsAttributeMap(allProcessorsAndCustomerIds))
|
||||
)).build();
|
||||
|
||||
return client.updateItem(request)
|
||||
.thenApply(updateItemResponse -> Record.from(userRecord.user, updateItemResponse.attributes()))
|
||||
.exceptionallyCompose(throwable -> {
|
||||
if (Throwables.getRootCause(throwable) instanceof ConditionalCheckFailedException) {
|
||||
return getUser(userRecord.user).thenApply(getItemResponse ->
|
||||
Record.from(userRecord.user, getItemResponse.item()));
|
||||
}
|
||||
Throwables.throwIfUnchecked(throwable);
|
||||
throw new CompletionException(throwable);
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, AttributeValue> createProcessorsToCustomerIdsAttributeMap(
|
||||
Map<SubscriptionProcessor, String> allProcessorsAndCustomerIds) {
|
||||
final Map<String, AttributeValue> result = new HashMap<>();
|
||||
|
||||
allProcessorsAndCustomerIds.forEach((processor, customerId) -> result.put(processor.name(), s(customerId)));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> accessedAt(byte[] user, Instant accessedAt) {
|
||||
checkUserLength(user);
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
public enum PaymentMethod {
|
||||
/**
|
||||
* A credit card or debit card, including those from Apple Pay and Google Pay
|
||||
*/
|
||||
CARD,
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.whispersystems.dispatch.util.Util;
|
||||
|
||||
public record ProcessorCustomer(String customerId, SubscriptionProcessor processor) {
|
||||
|
||||
public byte[] toDynamoBytes() {
|
||||
return Util.combine(new byte[]{processor.getId()}, customerId.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.stripe;
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.common.collect.Lists;
|
||||
|
@ -61,7 +61,7 @@ import javax.ws.rs.core.Response.Status;
|
|||
import org.apache.commons.codec.binary.Hex;
|
||||
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||
|
||||
public class StripeManager {
|
||||
public class StripeManager implements SubscriptionProcessorManager {
|
||||
|
||||
private static final String METADATA_KEY_LEVEL = "level";
|
||||
|
||||
|
@ -87,6 +87,16 @@ public class StripeManager {
|
|||
this.boostDescription = Objects.requireNonNull(boostDescription);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SubscriptionProcessor getProcessor() {
|
||||
return SubscriptionProcessor.STRIPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsPaymentMethod(PaymentMethod paymentMethod) {
|
||||
return paymentMethod == PaymentMethod.CARD;
|
||||
}
|
||||
|
||||
private RequestOptions commonOptions() {
|
||||
return commonOptions(null);
|
||||
}
|
||||
|
@ -98,17 +108,19 @@ public class StripeManager {
|
|||
.build();
|
||||
}
|
||||
|
||||
public CompletableFuture<Customer> createCustomer(byte[] subscriberUser) {
|
||||
@Override
|
||||
public CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
CustomerCreateParams params = CustomerCreateParams.builder()
|
||||
.putMetadata("subscriberUser", Hex.encodeHexString(subscriberUser))
|
||||
.build();
|
||||
try {
|
||||
return Customer.create(params, commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser)));
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor);
|
||||
CustomerCreateParams params = CustomerCreateParams.builder()
|
||||
.putMetadata("subscriberUser", Hex.encodeHexString(subscriberUser))
|
||||
.build();
|
||||
try {
|
||||
return Customer.create(params, commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser)));
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor)
|
||||
.thenApply(customer -> new ProcessorCustomer(customer.getId(), getProcessor()));
|
||||
}
|
||||
|
||||
public CompletableFuture<Customer> getCustomer(String customerId) {
|
||||
|
@ -139,17 +151,19 @@ public class StripeManager {
|
|||
}, executor);
|
||||
}
|
||||
|
||||
public CompletableFuture<SetupIntent> createSetupIntent(String customerId) {
|
||||
@Override
|
||||
public CompletableFuture<String> createPaymentMethodSetupToken(String customerId) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
SetupIntentCreateParams params = SetupIntentCreateParams.builder()
|
||||
.setCustomer(customerId)
|
||||
.build();
|
||||
try {
|
||||
return SetupIntent.create(params, commonOptions());
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor);
|
||||
SetupIntentCreateParams params = SetupIntentCreateParams.builder()
|
||||
.setCustomer(customerId)
|
||||
.build();
|
||||
try {
|
||||
return SetupIntent.create(params, commonOptions());
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor)
|
||||
.thenApply(SetupIntent::getClientSecret);
|
||||
}
|
||||
|
||||
/**
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A set of payment providers used for donations
|
||||
*/
|
||||
public enum SubscriptionProcessor {
|
||||
// because provider IDs are stored, they should not be reused, and great care
|
||||
// must be used if a provider is removed from the list
|
||||
STRIPE(1),
|
||||
;
|
||||
|
||||
private static final Map<Integer, SubscriptionProcessor> IDS_TO_PROCESSORS = new HashMap<>();
|
||||
|
||||
static {
|
||||
Arrays.stream(SubscriptionProcessor.values())
|
||||
.forEach(provider -> IDS_TO_PROCESSORS.put((int) provider.id, provider));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the provider associated with the given ID, or {@code null} if none exists
|
||||
*/
|
||||
public static SubscriptionProcessor forId(byte id) {
|
||||
return IDS_TO_PROCESSORS.get((int) id);
|
||||
}
|
||||
|
||||
private final byte id;
|
||||
|
||||
SubscriptionProcessor(int id) {
|
||||
if (id > 256) {
|
||||
throw new IllegalArgumentException("ID must fit in one byte: " + id);
|
||||
}
|
||||
|
||||
this.id = (byte) id;
|
||||
}
|
||||
|
||||
public byte getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public interface SubscriptionProcessorManager {
|
||||
|
||||
SubscriptionProcessor getProcessor();
|
||||
|
||||
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
|
||||
|
||||
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser);
|
||||
|
||||
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
|
||||
}
|
|
@ -5,12 +5,12 @@
|
|||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import software.amazon.awssdk.core.SdkBytes;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import software.amazon.awssdk.core.SdkBytes;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
|
||||
/** AwsAV provides static helper methods for working with AWS AttributeValues. */
|
||||
public class AttributeValues {
|
||||
|
@ -37,6 +37,9 @@ public class AttributeValues {
|
|||
return AttributeValue.builder().s(value).build();
|
||||
}
|
||||
|
||||
public static AttributeValue m(Map<String, AttributeValue> value) {
|
||||
return AttributeValue.builder().m(value).build();
|
||||
}
|
||||
|
||||
// More opinionated methods
|
||||
|
||||
|
|
|
@ -11,20 +11,27 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.b;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.n;
|
||||
|
||||
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
|
||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import javax.ws.rs.client.Entity;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.glassfish.jersey.server.ServerProperties;
|
||||
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
|
@ -42,9 +49,12 @@ import org.whispersystems.textsecuregcm.entities.Badge;
|
|||
import org.whispersystems.textsecuregcm.entities.BadgeSvg;
|
||||
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.stripe.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
|
||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||
class SubscriptionControllerTest {
|
||||
|
@ -72,6 +82,11 @@ class SubscriptionControllerTest {
|
|||
.addResource(SUBSCRIPTION_CONTROLLER)
|
||||
.build();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(STRIPE_MANAGER.getProcessor()).thenReturn(SubscriptionProcessor.STRIPE);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
reset(CLOCK, SUBSCRIPTION_CONFIG, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER,
|
||||
|
@ -95,19 +110,161 @@ class SubscriptionControllerTest {
|
|||
assertThat(response.getStatus()).isEqualTo(422);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSubscriber() {
|
||||
when(CLOCK.instant()).thenReturn(Instant.now());
|
||||
|
||||
// basic create
|
||||
final byte[] subscriberUserAndKey = new byte[32];
|
||||
Arrays.fill(subscriberUserAndKey, (byte) 1);
|
||||
final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);
|
||||
|
||||
when(SUBSCRIPTION_MANAGER.get(any(), any())).thenReturn(CompletableFuture.completedFuture(
|
||||
SubscriptionManager.GetResult.NOT_STORED));
|
||||
|
||||
final Map<String, AttributeValue> dynamoItem = Map.of(SubscriptionManager.KEY_PASSWORD, b(new byte[16]),
|
||||
SubscriptionManager.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),
|
||||
SubscriptionManager.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond())
|
||||
);
|
||||
final SubscriptionManager.Record record = SubscriptionManager.Record.from(
|
||||
Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);
|
||||
when(SUBSCRIPTION_MANAGER.create(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(record));
|
||||
|
||||
final Response createResponse = RESOURCE_EXTENSION.target(String.format("/v1/subscription/%s", subscriberId))
|
||||
.request()
|
||||
.put(Entity.json(""));
|
||||
assertThat(createResponse.getStatus()).isEqualTo(200);
|
||||
|
||||
// creating should be idempotent
|
||||
when(SUBSCRIPTION_MANAGER.get(any(), any())).thenReturn(CompletableFuture.completedFuture(
|
||||
SubscriptionManager.GetResult.found(record)));
|
||||
when(SUBSCRIPTION_MANAGER.accessedAt(any(), any())).thenReturn(CompletableFuture.completedFuture(null));
|
||||
|
||||
final Response idempotentCreateResponse = RESOURCE_EXTENSION.target(
|
||||
String.format("/v1/subscription/%s", subscriberId))
|
||||
.request()
|
||||
.put(Entity.json(""));
|
||||
assertThat(idempotentCreateResponse.getStatus()).isEqualTo(200);
|
||||
|
||||
// when the manager returns `null`, it means there was a password mismatch from the storage layer `create`.
|
||||
// this could happen if there is a race between two concurrent `create` requests for the same user ID
|
||||
when(SUBSCRIPTION_MANAGER.get(any(), any())).thenReturn(CompletableFuture.completedFuture(
|
||||
SubscriptionManager.GetResult.NOT_STORED));
|
||||
when(SUBSCRIPTION_MANAGER.create(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null));
|
||||
|
||||
final Response managerCreateNullResponse = RESOURCE_EXTENSION.target(
|
||||
String.format("/v1/subscription/%s", subscriberId))
|
||||
.request()
|
||||
.put(Entity.json(""));
|
||||
assertThat(managerCreateNullResponse.getStatus()).isEqualTo(403);
|
||||
|
||||
final byte[] subscriberUserAndMismatchedKey = new byte[32];
|
||||
Arrays.fill(subscriberUserAndMismatchedKey, 0, 16, (byte) 1);
|
||||
Arrays.fill(subscriberUserAndMismatchedKey, 16, 32, (byte) 2);
|
||||
final String mismatchedSubscriberId = Base64.getEncoder().encodeToString(subscriberUserAndMismatchedKey);
|
||||
|
||||
// a password mismatch for an existing record
|
||||
when(SUBSCRIPTION_MANAGER.get(any(), any())).thenReturn(CompletableFuture.completedFuture(
|
||||
SubscriptionManager.GetResult.PASSWORD_MISMATCH));
|
||||
|
||||
final Response passwordMismatchResponse = RESOURCE_EXTENSION.target(
|
||||
String.format("/v1/subscription/%s", mismatchedSubscriberId))
|
||||
.request()
|
||||
.put(Entity.json(""));
|
||||
|
||||
assertThat(passwordMismatchResponse.getStatus()).isEqualTo(403);
|
||||
|
||||
// invalid request data is a 404
|
||||
final byte[] malformedUserAndKey = new byte[16];
|
||||
Arrays.fill(malformedUserAndKey, (byte) 1);
|
||||
final String malformedUserId = Base64.getEncoder().encodeToString(malformedUserAndKey);
|
||||
|
||||
final Response malformedUserAndKeyResponse = RESOURCE_EXTENSION.target(
|
||||
String.format("/v1/subscription/%s", malformedUserId))
|
||||
.request()
|
||||
.put(Entity.json(""));
|
||||
|
||||
assertThat(malformedUserAndKeyResponse.getStatus()).isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPaymentMethod() {
|
||||
final byte[] subscriberUserAndKey = new byte[32];
|
||||
Arrays.fill(subscriberUserAndKey, (byte) 1);
|
||||
final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);
|
||||
|
||||
when(CLOCK.instant()).thenReturn(Instant.now());
|
||||
when(SUBSCRIPTION_MANAGER.get(any(), any())).thenReturn(CompletableFuture.completedFuture(
|
||||
SubscriptionManager.GetResult.NOT_STORED));
|
||||
|
||||
final Map<String, AttributeValue> dynamoItem = Map.of(SubscriptionManager.KEY_PASSWORD, b(new byte[16]),
|
||||
SubscriptionManager.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),
|
||||
SubscriptionManager.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond())
|
||||
);
|
||||
final SubscriptionManager.Record record = SubscriptionManager.Record.from(
|
||||
Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);
|
||||
when(SUBSCRIPTION_MANAGER.create(any(), any(), any(Instant.class)))
|
||||
.thenReturn(CompletableFuture.completedFuture(
|
||||
record));
|
||||
|
||||
final Response createSubscriberResponse = RESOURCE_EXTENSION
|
||||
.target(String.format("/v1/subscription/%s", subscriberId))
|
||||
.request()
|
||||
.put(Entity.json(""));
|
||||
|
||||
assertThat(createSubscriberResponse.getStatus()).isEqualTo(200);
|
||||
|
||||
when(SUBSCRIPTION_MANAGER.get(any(), any()))
|
||||
.thenReturn(CompletableFuture.completedFuture(SubscriptionManager.GetResult.found(record)));
|
||||
|
||||
final String customerId = "some-customer-id";
|
||||
final ProcessorCustomer customer = new ProcessorCustomer(
|
||||
customerId, SubscriptionProcessor.STRIPE);
|
||||
when(STRIPE_MANAGER.createCustomer(any()))
|
||||
.thenReturn(CompletableFuture.completedFuture(customer));
|
||||
|
||||
final SubscriptionManager.Record recordWithCustomerId = SubscriptionManager.Record.from(record.user, dynamoItem);
|
||||
recordWithCustomerId.customerId = customerId;
|
||||
recordWithCustomerId.processorsToCustomerIds.put(SubscriptionProcessor.STRIPE, customerId);
|
||||
|
||||
when(SUBSCRIPTION_MANAGER.updateProcessorAndCustomerId(any(SubscriptionManager.Record.class), any(),
|
||||
any(Instant.class)))
|
||||
.thenReturn(CompletableFuture.completedFuture(recordWithCustomerId));
|
||||
|
||||
final String clientSecret = "some-client-secret";
|
||||
when(STRIPE_MANAGER.createPaymentMethodSetupToken(customerId))
|
||||
.thenReturn(CompletableFuture.completedFuture(clientSecret));
|
||||
|
||||
final SubscriptionController.CreatePaymentMethodResponse createPaymentMethodResponse = RESOURCE_EXTENSION
|
||||
.target(String.format("/v1/subscription/%s/create_payment_method", subscriberId))
|
||||
.request()
|
||||
.post(Entity.json(""))
|
||||
.readEntity(SubscriptionController.CreatePaymentMethodResponse.class);
|
||||
|
||||
assertThat(createPaymentMethodResponse.processor()).isEqualTo(SubscriptionProcessor.STRIPE);
|
||||
assertThat(createPaymentMethodResponse.clientSecret()).isEqualTo(clientSecret);
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLevels() {
|
||||
when(SUBSCRIPTION_CONFIG.getLevels()).thenReturn(Map.of(
|
||||
1L, new SubscriptionLevelConfiguration("B1", "P1", Map.of("USD", new SubscriptionPriceConfiguration("R1", BigDecimal.valueOf(100)))),
|
||||
2L, new SubscriptionLevelConfiguration("B2", "P2", Map.of("USD", new SubscriptionPriceConfiguration("R2", BigDecimal.valueOf(200)))),
|
||||
3L, new SubscriptionLevelConfiguration("B3", "P3", Map.of("USD", new SubscriptionPriceConfiguration("R3", BigDecimal.valueOf(300))))
|
||||
1L, new SubscriptionLevelConfiguration("B1", "P1",
|
||||
Map.of("USD", new SubscriptionPriceConfiguration("R1", BigDecimal.valueOf(100)))),
|
||||
2L, new SubscriptionLevelConfiguration("B2", "P2",
|
||||
Map.of("USD", new SubscriptionPriceConfiguration("R2", BigDecimal.valueOf(200)))),
|
||||
3L, new SubscriptionLevelConfiguration("B3", "P3",
|
||||
Map.of("USD", new SubscriptionPriceConfiguration("R3", BigDecimal.valueOf(300))))
|
||||
));
|
||||
when(BADGE_TRANSLATOR.translate(any(), eq("B1"))).thenReturn(new Badge("B1", "cat1", "name1", "desc1",
|
||||
List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld"))));
|
||||
List.of("l", "m", "h", "x", "xx", "xxx"), "SVG",
|
||||
List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld"))));
|
||||
when(BADGE_TRANSLATOR.translate(any(), eq("B2"))).thenReturn(new Badge("B2", "cat2", "name2", "desc2",
|
||||
List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld"))));
|
||||
List.of("l", "m", "h", "x", "xx", "xxx"), "SVG",
|
||||
List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld"))));
|
||||
when(BADGE_TRANSLATOR.translate(any(), eq("B3"))).thenReturn(new Badge("B3", "cat3", "name3", "desc3",
|
||||
List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld"))));
|
||||
List.of("l", "m", "h", "x", "xx", "xxx"), "SVG",
|
||||
List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld"))));
|
||||
when(LEVEL_TRANSLATOR.translate(any(), eq("B1"))).thenReturn("Z1");
|
||||
when(LEVEL_TRANSLATOR.translate(any(), eq("B2"))).thenReturn("Z2");
|
||||
when(LEVEL_TRANSLATOR.translate(any(), eq("B3"))).thenReturn("Z3");
|
||||
|
|
|
@ -9,11 +9,16 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||
import static org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult.Type.FOUND;
|
||||
import static org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult.Type.NOT_STORED;
|
||||
import static org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult.Type.PASSWORD_MISMATCH;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.b;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.n;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.s;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import javax.annotation.Nonnull;
|
||||
|
@ -22,6 +27,8 @@ import org.junit.jupiter.api.Test;
|
|||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager.Record;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
||||
import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;
|
||||
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
|
||||
|
@ -85,8 +92,6 @@ class SubscriptionManagerTest {
|
|||
void testCreateOnlyOnce() {
|
||||
byte[] password1 = getRandomBytes(16);
|
||||
byte[] password2 = getRandomBytes(16);
|
||||
String customer1 = Base64.getEncoder().encodeToString(getRandomBytes(16));
|
||||
String customer2 = Base64.getEncoder().encodeToString(getRandomBytes(16));
|
||||
Instant created1 = Instant.ofEpochSecond(NOW_EPOCH_SECONDS);
|
||||
Instant created2 = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);
|
||||
|
||||
|
@ -103,16 +108,16 @@ class SubscriptionManagerTest {
|
|||
});
|
||||
|
||||
CompletableFuture<SubscriptionManager.Record> createFuture =
|
||||
subscriptionManager.create(user, password1, customer1, created1);
|
||||
Consumer<Record> recordRequirements = checkFreshlyCreatedRecord(user, password1, customer1, created1);
|
||||
subscriptionManager.create(user, password1, created1);
|
||||
Consumer<Record> recordRequirements = checkFreshlyCreatedRecord(user, password1, created1);
|
||||
assertThat(createFuture).succeedsWithin(Duration.ofSeconds(3)).satisfies(recordRequirements);
|
||||
|
||||
// password check fails so this should return null
|
||||
createFuture = subscriptionManager.create(user, password2, customer2, created2);
|
||||
createFuture = subscriptionManager.create(user, password2, created2);
|
||||
assertThat(createFuture).succeedsWithin(Duration.ofSeconds(3)).isNull();
|
||||
|
||||
// password check matches, but the record already exists so nothing should get updated
|
||||
createFuture = subscriptionManager.create(user, password1, customer2, created2);
|
||||
createFuture = subscriptionManager.create(user, password1, created2);
|
||||
assertThat(createFuture).succeedsWithin(Duration.ofSeconds(3)).satisfies(recordRequirements);
|
||||
}
|
||||
|
||||
|
@ -120,27 +125,67 @@ class SubscriptionManagerTest {
|
|||
void testGet() {
|
||||
byte[] wrongUser = getRandomBytes(16);
|
||||
byte[] wrongPassword = getRandomBytes(16);
|
||||
assertThat(subscriptionManager.create(user, password, customer, created)).succeedsWithin(Duration.ofSeconds(3));
|
||||
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3));
|
||||
|
||||
assertThat(subscriptionManager.get(user, password)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> {
|
||||
assertThat(getResult.type).isEqualTo(FOUND);
|
||||
assertThat(getResult.record).isNotNull().satisfies(checkFreshlyCreatedRecord(user, password, customer, created));
|
||||
assertThat(getResult.record).isNotNull().satisfies(checkFreshlyCreatedRecord(user, password, created));
|
||||
});
|
||||
|
||||
assertThat(subscriptionManager.get(user, wrongPassword)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> {
|
||||
assertThat(getResult.type).isEqualTo(PASSWORD_MISMATCH);
|
||||
assertThat(getResult.record).isNull();
|
||||
});
|
||||
assertThat(subscriptionManager.get(user, wrongPassword)).succeedsWithin(Duration.ofSeconds(3))
|
||||
.satisfies(getResult -> {
|
||||
assertThat(getResult.type).isEqualTo(PASSWORD_MISMATCH);
|
||||
assertThat(getResult.record).isNull();
|
||||
});
|
||||
|
||||
assertThat(subscriptionManager.get(wrongUser, password)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> {
|
||||
assertThat(getResult.type).isEqualTo(NOT_STORED);
|
||||
assertThat(getResult.record).isNull();
|
||||
});
|
||||
assertThat(subscriptionManager.get(wrongUser, password)).succeedsWithin(Duration.ofSeconds(3))
|
||||
.satisfies(getResult -> {
|
||||
assertThat(getResult.type).isEqualTo(NOT_STORED);
|
||||
assertThat(getResult.record).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookupByCustomerId() {
|
||||
assertThat(subscriptionManager.create(user, password, customer, created)).succeedsWithin(Duration.ofSeconds(3));
|
||||
void testUpdateCustomerIdAndProcessor() throws Exception {
|
||||
Instant subscriptionUpdated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);
|
||||
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3));
|
||||
|
||||
final CompletableFuture<GetResult> getUser = subscriptionManager.get(user, password);
|
||||
assertThat(getUser).succeedsWithin(Duration.ofSeconds(3));
|
||||
final Record userRecord = getUser.get().record;
|
||||
|
||||
assertThat(subscriptionManager.updateProcessorAndCustomerId(userRecord,
|
||||
new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE),
|
||||
subscriptionUpdated)).succeedsWithin(Duration.ofSeconds(3))
|
||||
.hasFieldOrPropertyWithValue("customerId", customer)
|
||||
.hasFieldOrPropertyWithValue("processorsToCustomerIds", Map.of(SubscriptionProcessor.STRIPE, customer));
|
||||
|
||||
assertThat(
|
||||
subscriptionManager.updateProcessorAndCustomerId(userRecord,
|
||||
new ProcessorCustomer(customer + "1", SubscriptionProcessor.STRIPE),
|
||||
subscriptionUpdated)).succeedsWithin(Duration.ofSeconds(3))
|
||||
.hasFieldOrPropertyWithValue("customerId", customer)
|
||||
.hasFieldOrPropertyWithValue("processorsToCustomerIds", Map.of(SubscriptionProcessor.STRIPE, customer));
|
||||
|
||||
// TODO test new customer ID with new processor does change the customer ID, once there is another processor
|
||||
|
||||
assertThat(subscriptionManager.getSubscriberUserByStripeCustomerId(customer))
|
||||
.succeedsWithin(Duration.ofSeconds(3)).
|
||||
isEqualTo(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookupByCustomerId() throws Exception {
|
||||
Instant subscriptionUpdated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);
|
||||
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3));
|
||||
|
||||
final CompletableFuture<GetResult> getUser = subscriptionManager.get(user, password);
|
||||
assertThat(getUser).succeedsWithin(Duration.ofSeconds(3));
|
||||
final Record userRecord = getUser.get().record;
|
||||
|
||||
assertThat(subscriptionManager.updateProcessorAndCustomerId(userRecord,
|
||||
new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE),
|
||||
subscriptionUpdated)).succeedsWithin(Duration.ofSeconds(3));
|
||||
assertThat(subscriptionManager.getSubscriberUserByStripeCustomerId(customer)).
|
||||
succeedsWithin(Duration.ofSeconds(3)).
|
||||
isEqualTo(user);
|
||||
|
@ -149,7 +194,7 @@ class SubscriptionManagerTest {
|
|||
@Test
|
||||
void testCanceledAt() {
|
||||
Instant canceled = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 42);
|
||||
assertThat(subscriptionManager.create(user, password, customer, created)).succeedsWithin(Duration.ofSeconds(3));
|
||||
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3));
|
||||
assertThat(subscriptionManager.canceledAt(user, canceled)).succeedsWithin(Duration.ofSeconds(3));
|
||||
assertThat(subscriptionManager.get(user, password)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> {
|
||||
assertThat(getResult).isNotNull();
|
||||
|
@ -167,7 +212,7 @@ class SubscriptionManagerTest {
|
|||
String subscriptionId = Base64.getEncoder().encodeToString(getRandomBytes(16));
|
||||
Instant subscriptionCreated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);
|
||||
long level = 42;
|
||||
assertThat(subscriptionManager.create(user, password, customer, created)).succeedsWithin(Duration.ofSeconds(3));
|
||||
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3));
|
||||
assertThat(subscriptionManager.subscriptionCreated(user, subscriptionId, subscriptionCreated, level)).
|
||||
succeedsWithin(Duration.ofSeconds(3));
|
||||
assertThat(subscriptionManager.get(user, password)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> {
|
||||
|
@ -187,7 +232,7 @@ class SubscriptionManagerTest {
|
|||
void testSubscriptionLevelChanged() {
|
||||
Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500);
|
||||
long level = 1776;
|
||||
assertThat(subscriptionManager.create(user, password, customer, created)).succeedsWithin(Duration.ofSeconds(3));
|
||||
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3));
|
||||
assertThat(subscriptionManager.subscriptionLevelChanged(user, at, level)).succeedsWithin(Duration.ofSeconds(3));
|
||||
assertThat(subscriptionManager.get(user, password)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> {
|
||||
assertThat(getResult).isNotNull();
|
||||
|
@ -200,6 +245,74 @@ class SubscriptionManagerTest {
|
|||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSubscriptionAddProcessorAttribute() throws Exception {
|
||||
|
||||
final byte[] user = new byte[16];
|
||||
Arrays.fill(user, (byte) 1);
|
||||
final byte[] hmac = new byte[16];
|
||||
Arrays.fill(hmac, (byte) 2);
|
||||
final String customerId = "abcdef";
|
||||
|
||||
// manually create an existing record, with only KEY_CUSTOMER_ID
|
||||
dynamoDbExtension.getDynamoDbClient().putItem(p ->
|
||||
p.tableName(dynamoDbExtension.getTableName())
|
||||
.item(Map.of(
|
||||
SubscriptionManager.KEY_USER, b(user),
|
||||
SubscriptionManager.KEY_PASSWORD, b(hmac),
|
||||
SubscriptionManager.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),
|
||||
SubscriptionManager.KEY_CUSTOMER_ID, s(customerId),
|
||||
SubscriptionManager.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond())
|
||||
))
|
||||
);
|
||||
|
||||
final CompletableFuture<GetResult> firstGetResult = subscriptionManager.get(user, hmac);
|
||||
assertThat(firstGetResult).succeedsWithin(Duration.ofSeconds(1));
|
||||
|
||||
final Record firstRecord = firstGetResult.get().record;
|
||||
|
||||
assertThat(firstRecord.customerId).isEqualTo(customerId);
|
||||
assertThat(firstRecord.processor).isNull();
|
||||
assertThat(firstRecord.processorsToCustomerIds).isEmpty();
|
||||
|
||||
// Try to update the user to have a different customer ID. This should quietly fail,
|
||||
// and just return the existing customer ID.
|
||||
final CompletableFuture<Record> firstUpdate = subscriptionManager.updateProcessorAndCustomerId(firstRecord,
|
||||
new ProcessorCustomer(customerId + "something else", SubscriptionProcessor.STRIPE),
|
||||
Instant.now());
|
||||
|
||||
assertThat(firstUpdate).succeedsWithin(Duration.ofSeconds(1));
|
||||
|
||||
final String firstUpdateCustomerId = firstUpdate.get().customerId;
|
||||
assertThat(firstUpdateCustomerId).isEqualTo(customerId);
|
||||
|
||||
// Now update with the existing customer ID. All fields should now be populated.
|
||||
final CompletableFuture<Record> secondUpdate = subscriptionManager.updateProcessorAndCustomerId(firstRecord,
|
||||
new ProcessorCustomer(customerId, SubscriptionProcessor.STRIPE), Instant.now());
|
||||
|
||||
assertThat(secondUpdate).succeedsWithin(Duration.ofSeconds(1));
|
||||
|
||||
final String secondUpdateCustomerId = secondUpdate.get().customerId;
|
||||
assertThat(secondUpdateCustomerId).isEqualTo(customerId);
|
||||
|
||||
final CompletableFuture<GetResult> secondGetResult = subscriptionManager.get(user, hmac);
|
||||
assertThat(secondGetResult).succeedsWithin(Duration.ofSeconds(1));
|
||||
|
||||
final Record secondRecord = secondGetResult.get().record;
|
||||
|
||||
assertThat(secondRecord.customerId).isEqualTo(customerId);
|
||||
assertThat(secondRecord.processor).isEqualTo(SubscriptionProcessor.STRIPE);
|
||||
assertThat(secondRecord.processorsToCustomerIds).isEqualTo(Map.of(SubscriptionProcessor.STRIPE, customerId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testProcessorAndCustomerId() {
|
||||
final ProcessorCustomer processorCustomer =
|
||||
new ProcessorCustomer("abc", SubscriptionProcessor.STRIPE);
|
||||
|
||||
assertThat(processorCustomer.toDynamoBytes()).isEqualTo(new byte[]{1, 97, 98, 99});
|
||||
}
|
||||
|
||||
private static byte[] getRandomBytes(int length) {
|
||||
byte[] result = new byte[length];
|
||||
SECURE_RANDOM.nextBytes(result);
|
||||
|
@ -208,12 +321,12 @@ class SubscriptionManagerTest {
|
|||
|
||||
@Nonnull
|
||||
private static Consumer<Record> checkFreshlyCreatedRecord(
|
||||
byte[] user, byte[] password, String customer, Instant created) {
|
||||
byte[] user, byte[] password, Instant created) {
|
||||
return record -> {
|
||||
assertThat(record).isNotNull();
|
||||
assertThat(record.user).isEqualTo(user);
|
||||
assertThat(record.password).isEqualTo(password);
|
||||
assertThat(record.customerId).isEqualTo(customer);
|
||||
assertThat(record.customerId).isNull();
|
||||
assertThat(record.createdAt).isEqualTo(created);
|
||||
assertThat(record.subscriptionId).isNull();
|
||||
assertThat(record.subscriptionCreatedAt).isNull();
|
||||
|
|
Loading…
Reference in New Issue