diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index a974ae26e..e735ded3b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -252,6 +252,7 @@ import org.whispersystems.textsecuregcm.storage.VerificationSessions; import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager; import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckTrustAnchor; import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceChecks; +import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreClient; import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager; import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; @@ -767,12 +768,17 @@ public class WhisperServerService extends Application base64AppleRootCerts, + @Nullable final String retryConfigurationName) { + this(defaultEnvironment, + new SignedDataVerifier(decodeRootCerts(base64AppleRootCerts), bundleId, appAppleId, Environment.PRODUCTION, + true), + new AppStoreServerAPIClient(encodedKey, keyId, issuerId, bundleId, Environment.PRODUCTION), + new SignedDataVerifier(decodeRootCerts(base64AppleRootCerts), bundleId, appAppleId, Environment.SANDBOX, true), + new AppStoreServerAPIClient(encodedKey, keyId, issuerId, bundleId, Environment.SANDBOX), + retryConfigurationName); + } + + @VisibleForTesting + AppleAppStoreClient( + Environment defaultEnvironment, + SignedDataVerifier productionSignedDataVerifier, + AppStoreServerAPIClient productionApiClient, + SignedDataVerifier sandboxSignedDataVerifier, + AppStoreServerAPIClient sandboxApiClient, + @Nullable final String retryConfigurationName) { + this.defaultEnvironment = defaultEnvironment; + this.sandboxSignedDataVerifier = sandboxSignedDataVerifier; + this.sandboxApiClient = sandboxApiClient; + this.productionSignedDataVerifier = productionSignedDataVerifier; + this.productionApiClient = productionApiClient; + this.retry = ResilienceUtil.getRetryRegistry().retry("appstore-retry", RetryConfig + .>from(Optional.ofNullable(retryConfigurationName) + .flatMap(name -> ResilienceUtil.getRetryRegistry().getConfiguration(name)) + .orElseGet(() -> ResilienceUtil.getRetryRegistry().getDefaultConfig())) + .retryOnException(AppleAppStoreClient::shouldRetry).build()); + } + + + /// Verify signature and decode transaction payloads + public AppleAppStoreDecodedTransaction verify(final Environment environment, final LastTransactionsItem tx) { + final SignedDataVerifier signedDataVerifier = switch (environment) { + case PRODUCTION -> productionSignedDataVerifier; + case SANDBOX -> sandboxSignedDataVerifier; + default -> throw new IllegalStateException("Unexpected environment: " + environment); + }; + try { + return new AppleAppStoreDecodedTransaction( + tx, + signedDataVerifier.verifyAndDecodeTransaction(tx.getSignedTransactionInfo()), + signedDataVerifier.verifyAndDecodeRenewalInfo(tx.getSignedRenewalInfo())); + } catch (VerificationException e) { + throw new UncheckedIOException(new IOException("Failed to verify payload from App Store Server", e)); + } + } + + public StatusResponse getAllSubscriptions(final String originalTransactionId) + throws SubscriptionNotFoundException, SubscriptionInvalidArgumentsException, RateLimitExceededException { + try { + return retry.executeCallable(() -> { + try { + return getAllSubscriptionsHelper(defaultEnvironment, originalTransactionId); + } catch (final APIException e) { + Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", e.getApiError().name()).increment(); + throw switch (e.getApiError()) { + case TRANSACTION_ID_NOT_FOUND, ORIGINAL_TRANSACTION_ID_NOT_FOUND -> new SubscriptionNotFoundException(); + case RATE_LIMIT_EXCEEDED -> new RateLimitExceededException(null); + case INVALID_ORIGINAL_TRANSACTION_ID -> new SubscriptionInvalidArgumentsException(e.getApiErrorMessage()); + default -> throw e; + }; + } catch (final IOException e) { + Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", "io_error").increment(); + throw e; + } + }); + } catch (SubscriptionNotFoundException | SubscriptionInvalidArgumentsException | RateLimitExceededException e) { + throw e; + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (APIException e) { + throw new UncheckedIOException(new IOException(e)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private StatusResponse getAllSubscriptionsHelper(final Environment env, final String originalTransactionId) + throws APIException, IOException { + final AppStoreServerAPIClient client = switch (env) { + case SANDBOX -> sandboxApiClient; + case PRODUCTION -> productionApiClient; + default -> throw new IllegalArgumentException("Unknown environment: " + env); + }; + try { + return client.getAllSubscriptionStatuses(originalTransactionId, EMPTY_STATUSES); + } catch (APIException e) { + // First attempts to look up the transaction on the production environment, falling back to the sandbox env if + // the transaction is not found. + // See: https://developer.apple.com/documentation/AppStoreServerAPI#Test-using-the-sandbox-environment + if (env == Environment.PRODUCTION && e.getApiError() == APIError.TRANSACTION_ID_NOT_FOUND) { + return getAllSubscriptionsHelper(Environment.SANDBOX, originalTransactionId); + } + throw e; + } + } + + private static Set decodeRootCerts(final List rootCerts) { + return rootCerts.stream() + .map(Base64.getDecoder()::decode) + .map(ByteArrayInputStream::new) + .collect(Collectors.toSet()); + } + + private static boolean shouldRetry(Throwable e) { + return e instanceof APIException apiException && switch (apiException.getApiError()) { + case ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE, GENERAL_INTERNAL_RETRYABLE, APP_NOT_FOUND_RETRYABLE -> true; + default -> false; + }; + } + + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreDecodedTransaction.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreDecodedTransaction.java new file mode 100644 index 000000000..166fb6e5a --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreDecodedTransaction.java @@ -0,0 +1,17 @@ +package org.whispersystems.textsecuregcm.subscriptions; + +import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload; +import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; +import com.apple.itunes.storekit.model.LastTransactionsItem; + +/** + * A decoded and validated storekit transaction + * + * @param signedTransaction The transaction + * @param transaction The transaction info with a validated signature + * @param renewalInfo The renewal info with a validated signature + */ +record AppleAppStoreDecodedTransaction( + LastTransactionsItem signedTransaction, + JWSTransactionDecodedPayload transaction, + JWSRenewalInfoDecodedPayload renewalInfo) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java index 8d9b04076..5a8fcab3c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java @@ -5,43 +5,21 @@ package org.whispersystems.textsecuregcm.subscriptions; -import com.apple.itunes.storekit.client.APIException; -import com.apple.itunes.storekit.client.AppStoreServerAPIClient; import com.apple.itunes.storekit.model.AutoRenewStatus; -import com.apple.itunes.storekit.model.Environment; -import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload; -import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; -import com.apple.itunes.storekit.model.LastTransactionsItem; import com.apple.itunes.storekit.model.Status; import com.apple.itunes.storekit.model.StatusResponse; import com.apple.itunes.storekit.model.SubscriptionGroupIdentifierItem; -import com.apple.itunes.storekit.verification.SignedDataVerifier; -import com.apple.itunes.storekit.verification.VerificationException; -import com.google.common.annotations.VisibleForTesting; -import io.github.resilience4j.retry.Retry; -import io.github.resilience4j.retry.RetryConfig; -import io.micrometer.core.instrument.Metrics; -import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.InputStream; import java.io.UncheckedIOException; import java.math.BigDecimal; -import java.net.http.HttpResponse; import java.time.Instant; -import java.util.Base64; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.metrics.MetricsUtil; import org.whispersystems.textsecuregcm.storage.PaymentTime; -import org.whispersystems.textsecuregcm.util.ResilienceUtil; /** * Manages subscriptions made with the Apple App Store @@ -54,51 +32,17 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { private static final Logger logger = LoggerFactory.getLogger(AppleAppStoreManager.class); - private final AppStoreServerAPIClient apiClient; - private final SignedDataVerifier signedDataVerifier; + private final AppleAppStoreClient appleAppStoreClient; private final Map productIdToLevel; - - private static final Status[] EMPTY_STATUSES = new Status[0]; - - private static final String GET_SUBSCRIPTION_ERROR_COUNTER_NAME = - MetricsUtil.name(AppleAppStoreManager.class, "getSubscriptionsError"); - private final String subscriptionGroupId; - private final Retry retry; - public AppleAppStoreManager( - final Environment env, - final String bundleId, - final long appAppleId, - final String issuerId, - final String keyId, - final String encodedKey, + AppleAppStoreClient appleAppStoreClient, final String subscriptionGroupId, - final Map productIdToLevel, - final List base64AppleRootCerts, - @Nullable final String retryConfigurationName) { - this(new AppStoreServerAPIClient(encodedKey, keyId, issuerId, bundleId, env), - new SignedDataVerifier(decodeRootCerts(base64AppleRootCerts), bundleId, appAppleId, env, true), - subscriptionGroupId, productIdToLevel, retryConfigurationName); - } - - @VisibleForTesting - AppleAppStoreManager( - final AppStoreServerAPIClient apiClient, - final SignedDataVerifier signedDataVerifier, - final String subscriptionGroupId, - final Map productIdToLevel, - @Nullable final String retryConfigurationName) { - this.apiClient = apiClient; - this.signedDataVerifier = signedDataVerifier; + final Map productIdToLevel) { + this.appleAppStoreClient = appleAppStoreClient; this.subscriptionGroupId = subscriptionGroupId; this.productIdToLevel = productIdToLevel; - this.retry = ResilienceUtil.getRetryRegistry().retry("appstore-retry", RetryConfig - .>from(Optional.ofNullable(retryConfigurationName) - .flatMap(name -> ResilienceUtil.getRetryRegistry().getConfiguration(name)) - .orElseGet(() -> ResilienceUtil.getRetryRegistry().getDefaultConfig())) - .retryOnException(AppleAppStoreManager::shouldRetry).build()); } @Override @@ -106,7 +50,6 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { return PaymentProvider.APPLE_APP_STORE; } - /** * Check if the subscription with the provided originalTransactionId is valid. * @@ -120,7 +63,7 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { */ public Long validateTransaction(final String originalTransactionId) throws SubscriptionInvalidArgumentsException, RateLimitExceededException, SubscriptionNotFoundException, SubscriptionPaymentRequiredException { - final DecodedTransaction tx = lookupAndValidateTransaction(originalTransactionId); + final AppleAppStoreDecodedTransaction tx = lookupAndValidateTransaction(originalTransactionId); if (!isSubscriptionActive(tx)) { throw new SubscriptionPaymentRequiredException(); } @@ -144,10 +87,10 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { public void cancelAllActiveSubscriptions(String originalTransactionId) throws SubscriptionInvalidArgumentsException, RateLimitExceededException { try { - final DecodedTransaction tx = lookup(originalTransactionId); - if (tx.signedTransaction.getStatus() != Status.EXPIRED && - tx.signedTransaction.getStatus() != Status.REVOKED && - tx.renewalInfo.getAutoRenewStatus() != AutoRenewStatus.OFF) { + final AppleAppStoreDecodedTransaction tx = lookup(originalTransactionId); + if (tx.signedTransaction().getStatus() != Status.EXPIRED && + tx.signedTransaction().getStatus() != Status.REVOKED && + tx.renewalInfo().getAutoRenewStatus() != AutoRenewStatus.OFF) { throw new SubscriptionInvalidArgumentsException("must cancel subscription with storekit before deleting"); } } catch (SubscriptionNotFoundException _) { @@ -159,8 +102,8 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { @Override public SubscriptionInformation getSubscriptionInformation(final String originalTransactionId) throws RateLimitExceededException, SubscriptionNotFoundException { - final DecodedTransaction tx = lookup(originalTransactionId); - final SubscriptionStatus status = switch (tx.signedTransaction.getStatus()) { + final AppleAppStoreDecodedTransaction tx = lookup(originalTransactionId); + final SubscriptionStatus status = switch (tx.signedTransaction().getStatus()) { case ACTIVE -> SubscriptionStatus.ACTIVE; case BILLING_RETRY -> SubscriptionStatus.PAST_DUE; case BILLING_GRACE_PERIOD -> SubscriptionStatus.UNPAID; @@ -170,10 +113,10 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { return new SubscriptionInformation( getSubscriptionPrice(tx), getLevel(tx), - Instant.ofEpochMilli(tx.transaction.getOriginalPurchaseDate()), - Instant.ofEpochMilli(tx.transaction.getExpiresDate()), + Instant.ofEpochMilli(tx.transaction().getOriginalPurchaseDate()), + Instant.ofEpochMilli(tx.transaction().getExpiresDate()), isSubscriptionActive(tx), - tx.renewalInfo.getAutoRenewStatus() == AutoRenewStatus.OFF, + tx.renewalInfo().getAutoRenewStatus() == AutoRenewStatus.OFF, status, PaymentProvider.APPLE_APP_STORE, PaymentMethod.APPLE_APP_STORE, @@ -185,7 +128,7 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { @Override public ReceiptItem getReceiptItem(String originalTransactionId) throws RateLimitExceededException, SubscriptionNotFoundException, SubscriptionPaymentRequiredException { - final DecodedTransaction tx = lookup(originalTransactionId); + final AppleAppStoreDecodedTransaction tx = lookup(originalTransactionId); if (!isSubscriptionActive(tx)) { throw new SubscriptionPaymentRequiredException(); } @@ -193,14 +136,14 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { // A new transactionId might be generated if you restore a subscription on a new device. webOrderLineItemId is // guaranteed not to change for a specific renewal purchase. // See: https://developer.apple.com/documentation/appstoreservernotifications/weborderlineitemid - final String itemId = tx.transaction.getWebOrderLineItemId(); - final PaymentTime paymentTime = PaymentTime.periodEnds(Instant.ofEpochMilli(tx.transaction.getExpiresDate())); + final String itemId = tx.transaction().getWebOrderLineItemId(); + final PaymentTime paymentTime = PaymentTime.periodEnds(Instant.ofEpochMilli(tx.transaction().getExpiresDate())); return new ReceiptItem(itemId, paymentTime, getLevel(tx)); } - private DecodedTransaction lookup(final String originalTransactionId) + private AppleAppStoreDecodedTransaction lookup(final String originalTransactionId) throws RateLimitExceededException, SubscriptionNotFoundException { try { return lookupAndValidateTransaction(originalTransactionId); @@ -210,16 +153,16 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { } } - private DecodedTransaction lookupAndValidateTransaction(final String originalTransactionId) + private AppleAppStoreDecodedTransaction lookupAndValidateTransaction(final String originalTransactionId) throws SubscriptionInvalidArgumentsException, RateLimitExceededException, SubscriptionNotFoundException { - final StatusResponse statuses = getAllSubscriptions(originalTransactionId); + final StatusResponse statuses = appleAppStoreClient.getAllSubscriptions(originalTransactionId); final SubscriptionGroupIdentifierItem item = statuses.getData().stream() .filter(s -> subscriptionGroupId.equals(s.getSubscriptionGroupIdentifier())).findFirst() .orElseThrow(() -> new SubscriptionInvalidArgumentsException("transaction did not contain a backup subscription", null)); - final List txs = item.getLastTransactions().stream() - .map(this::decode) - .filter(decoded -> productIdToLevel.containsKey(decoded.transaction.getProductId())) + final List txs = item.getLastTransactions().stream() + .map(txItem -> appleAppStoreClient.verify(statuses.getEnvironment(), txItem)) + .filter(decoded -> productIdToLevel.containsKey(decoded.transaction().getProductId())) .toList(); if (txs.isEmpty()) { @@ -231,7 +174,7 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { originalTransactionId); } - if (!originalTransactionId.equals(txs.getFirst().signedTransaction.getOriginalTransactionId())) { + if (!originalTransactionId.equals(txs.getFirst().signedTransaction().getOriginalTransactionId())) { // Get All Subscriptions only requires that the transaction be some transaction associated with the // subscription. This is too flexible, since we'd like to key on the originalTransactionId in the // SubscriptionManager. @@ -240,74 +183,18 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { return txs.getFirst(); } - private StatusResponse getAllSubscriptions(final String originalTransactionId) - throws SubscriptionNotFoundException, SubscriptionInvalidArgumentsException, RateLimitExceededException { - try { - return retry.executeCallable(() -> { - try { - return apiClient.getAllSubscriptionStatuses(originalTransactionId, EMPTY_STATUSES); - } catch (final APIException e) { - Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", e.getApiError().name()).increment(); - throw switch (e.getApiError()) { - case ORIGINAL_TRANSACTION_ID_NOT_FOUND, TRANSACTION_ID_NOT_FOUND -> new SubscriptionNotFoundException(); - case RATE_LIMIT_EXCEEDED -> new RateLimitExceededException(null); - case INVALID_ORIGINAL_TRANSACTION_ID -> new SubscriptionInvalidArgumentsException(e.getApiErrorMessage()); - default -> e; - }; - } catch (final IOException e) { - Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", "io_error").increment(); - throw e; - } - }); - } catch (SubscriptionNotFoundException | SubscriptionInvalidArgumentsException | RateLimitExceededException e) { - throw e; - } catch (IOException e) { - throw new UncheckedIOException(e); - } catch (APIException e) { - throw new UncheckedIOException(new IOException(e)); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private static boolean shouldRetry(Throwable e) { - return e instanceof APIException apiException && switch (apiException.getApiError()) { - case ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE, GENERAL_INTERNAL_RETRYABLE, APP_NOT_FOUND_RETRYABLE -> true; - default -> false; - }; - } - - private record DecodedTransaction( - LastTransactionsItem signedTransaction, - JWSTransactionDecodedPayload transaction, - JWSRenewalInfoDecodedPayload renewalInfo) {} - - /** - * Verify signature and decode transaction payloads - */ - private DecodedTransaction decode(final LastTransactionsItem tx) { - try { - return new DecodedTransaction( - tx, - signedDataVerifier.verifyAndDecodeTransaction(tx.getSignedTransactionInfo()), - signedDataVerifier.verifyAndDecodeRenewalInfo(tx.getSignedRenewalInfo())); - } catch (VerificationException e) { - throw new UncheckedIOException(new IOException("Failed to verify payload from App Store Server", e)); - } - } - - private SubscriptionPrice getSubscriptionPrice(final DecodedTransaction tx) { - final BigDecimal amount = new BigDecimal(tx.transaction.getPrice()).scaleByPowerOfTen(-3); + private SubscriptionPrice getSubscriptionPrice(final AppleAppStoreDecodedTransaction tx) { + final BigDecimal amount = new BigDecimal(tx.transaction().getPrice()).scaleByPowerOfTen(-3); return new SubscriptionPrice( - tx.transaction.getCurrency().toUpperCase(Locale.ROOT), - SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(tx.transaction.getCurrency(), amount)); + tx.transaction().getCurrency().toUpperCase(Locale.ROOT), + SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(tx.transaction().getCurrency(), amount)); } - private long getLevel(final DecodedTransaction tx) { - final Long level = productIdToLevel.get(tx.transaction.getProductId()); + private long getLevel(final AppleAppStoreDecodedTransaction tx) { + final Long level = productIdToLevel.get(tx.transaction().getProductId()); if (level == null) { throw new UncheckedIOException(new IOException( - "Transaction for unknown productId " + tx.transaction.getProductId())); + "Transaction for unknown productId " + tx.transaction().getProductId())); } return level; } @@ -315,16 +202,9 @@ public class AppleAppStoreManager implements SubscriptionPaymentProcessor { /** * Return true if the subscription's entitlement can currently be granted */ - private boolean isSubscriptionActive(final DecodedTransaction tx) { - return tx.signedTransaction.getStatus() == Status.ACTIVE - || tx.signedTransaction.getStatus() == Status.BILLING_GRACE_PERIOD; - } - - private static Set decodeRootCerts(final List rootCerts) { - return rootCerts.stream() - .map(Base64.getDecoder()::decode) - .map(ByteArrayInputStream::new) - .collect(Collectors.toSet()); + private boolean isSubscriptionActive(final AppleAppStoreDecodedTransaction tx) { + return tx.signedTransaction().getStatus() == Status.ACTIVE + || tx.signedTransaction().getStatus() == Status.BILLING_GRACE_PERIOD; } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java index d283c0c3c..ea58de08f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java @@ -77,6 +77,7 @@ import org.whispersystems.textsecuregcm.storage.SingleUseECPreKeyStore; import org.whispersystems.textsecuregcm.storage.SingleUseKEMPreKeyStore; import org.whispersystems.textsecuregcm.storage.SubscriptionManager; import org.whispersystems.textsecuregcm.storage.Subscriptions; +import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreClient; import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager; import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager; import org.whispersystems.textsecuregcm.util.ManagedAwsCrt; @@ -329,16 +330,17 @@ record CommandDependencies( configuration.getGooglePlayBilling().applicationName(), configuration.getGooglePlayBilling().productIdToLevel()); AppleAppStoreManager appleAppStoreManager = new AppleAppStoreManager( - configuration.getAppleAppStore().env(), - configuration.getAppleAppStore().bundleId(), - configuration.getAppleAppStore().appAppleId(), - configuration.getAppleAppStore().issuerId(), - configuration.getAppleAppStore().keyId(), - configuration.getAppleAppStore().encodedKey().value(), + new AppleAppStoreClient( + configuration.getAppleAppStore().env(), + configuration.getAppleAppStore().bundleId(), + configuration.getAppleAppStore().appAppleId(), + configuration.getAppleAppStore().issuerId(), + configuration.getAppleAppStore().keyId(), + configuration.getAppleAppStore().encodedKey().value(), + configuration.getAppleAppStore().appleRootCerts(), + configuration.getAppleAppStore().retryConfigurationName()), configuration.getAppleAppStore().subscriptionGroupId(), - configuration.getAppleAppStore().productIdToLevel(), - configuration.getAppleAppStore().appleRootCerts(), - configuration.getAppleAppStore().retryConfigurationName()); + configuration.getAppleAppStore().productIdToLevel()); final SubscriptionManager subscriptionManager = new SubscriptionManager( new Subscriptions(configuration.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbAsyncClient), List.of(googlePlayBillingManager, appleAppStoreManager), diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreClientTest.java new file mode 100644 index 000000000..fe01adfd8 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreClientTest.java @@ -0,0 +1,155 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.subscriptions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatException; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.apple.itunes.storekit.client.APIError; +import com.apple.itunes.storekit.client.APIException; +import com.apple.itunes.storekit.client.AppStoreServerAPIClient; +import com.apple.itunes.storekit.model.Environment; +import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload; +import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; +import com.apple.itunes.storekit.model.LastTransactionsItem; +import com.apple.itunes.storekit.model.Status; +import com.apple.itunes.storekit.model.StatusResponse; +import com.apple.itunes.storekit.verification.SignedDataVerifier; +import com.apple.itunes.storekit.verification.VerificationException; +import java.io.IOException; +import java.io.UncheckedIOException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; + +class AppleAppStoreClientTest { + + private final static String ORIGINAL_TX_ID = "originalTxIdTest"; + private final static String SIGNED_RENEWAL_INFO = "signedRenewalInfoTest"; + private final static String SIGNED_TX_INFO = "signedRenewalInfoTest"; + private final static String PRODUCT_ID = "productIdTest"; + + private final AppStoreServerAPIClient productionClient = mock(AppStoreServerAPIClient.class); + private final AppStoreServerAPIClient sandboxClient = mock(AppStoreServerAPIClient.class); + private final SignedDataVerifier productionSignedDataVerifier = mock(SignedDataVerifier.class); + private final SignedDataVerifier sandboxSignedDataVerifier = mock(SignedDataVerifier.class); + private AppleAppStoreClient apiWrapper; + + @BeforeEach + public void setup() { + reset(productionClient, productionSignedDataVerifier, sandboxClient, sandboxSignedDataVerifier); + apiWrapper = new AppleAppStoreClient(Environment.PRODUCTION, productionSignedDataVerifier, productionClient, + sandboxSignedDataVerifier, sandboxClient, null); + } + + @ParameterizedTest + @EnumSource(value = APIError.class, mode = EnumSource.Mode.EXCLUDE, names = "TRANSACTION_ID_NOT_FOUND") + public void noFallbackOnOtherErrors(APIError error) throws APIException, IOException { + when(productionClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) + .thenThrow(new APIException(404, error, "test")); + + assertThatException().isThrownBy(() -> apiWrapper.getAllSubscriptions(ORIGINAL_TX_ID)); + verifyNoInteractions(sandboxClient); + } + + @Test + public void fallbackOnNoTransactionFound() + throws APIException, IOException, SubscriptionInvalidArgumentsException, SubscriptionNotFoundException, RateLimitExceededException { + when(productionClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) + .thenThrow(new APIException(404, APIError.TRANSACTION_ID_NOT_FOUND, "test")); + + when(sandboxClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) + .thenReturn(new StatusResponse().environment(Environment.SANDBOX)); + + final StatusResponse allSubscriptions = apiWrapper.getAllSubscriptions(ORIGINAL_TX_ID); + + assertThat(allSubscriptions.getEnvironment()).isEqualTo(Environment.SANDBOX); + verify(productionClient).getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}); + verify(sandboxClient).getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}); + } + + @Test + public void retryEventuallyWorks() + throws APIException, IOException, VerificationException, RateLimitExceededException, SubscriptionException { + // Should retry up to 3 times + when(productionClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) + .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test")) + .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test")) + .thenReturn(new StatusResponse().environment(Environment.PRODUCTION)); + final StatusResponse statusResponse = apiWrapper.getAllSubscriptions(ORIGINAL_TX_ID); + assertThat(statusResponse.getEnvironment()).isEqualTo(Environment.PRODUCTION); + verifyNoInteractions(sandboxClient); + } + + @Test + public void retryEventuallyGivesUp() throws APIException, IOException, VerificationException { + // Should retry up to 3 times + when(productionClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) + .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test")); + assertThatException() + .isThrownBy(() -> apiWrapper.getAllSubscriptions(ORIGINAL_TX_ID)) + .isInstanceOf(UncheckedIOException.class) + .withRootCauseInstanceOf(APIException.class); + + verify(productionClient, times(3)).getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}); + verifyNoInteractions(sandboxClient); + } + + @Test + public void sandboxDoesRetries() + throws APIException, IOException, SubscriptionInvalidArgumentsException, SubscriptionNotFoundException, RateLimitExceededException { + when(productionClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) + .thenThrow(new APIException(404, APIError.TRANSACTION_ID_NOT_FOUND, "test")); + + when(sandboxClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) + .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test")) + .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test")) + .thenReturn(new StatusResponse().environment(Environment.SANDBOX)); + + final StatusResponse statusResponse = apiWrapper.getAllSubscriptions(ORIGINAL_TX_ID); + assertThat(statusResponse.getEnvironment()).isEqualTo(Environment.SANDBOX); + + verify(productionClient, times(3)) + .getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}); + verify(sandboxClient, times(3)) + .getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}); + } + + @ParameterizedTest + @EnumSource(value = Environment.class, mode = EnumSource.Mode.INCLUDE, names = {"SANDBOX", "PRODUCTION"}) + public void verifySignatureTest(Environment environment) throws VerificationException { + final SignedDataVerifier expectedVerifier, unexpectedVerifier; + if (environment.equals(Environment.SANDBOX)) { + expectedVerifier = sandboxSignedDataVerifier; + unexpectedVerifier = productionSignedDataVerifier; + } else { + expectedVerifier = productionSignedDataVerifier; + unexpectedVerifier = sandboxSignedDataVerifier; + } + + when(expectedVerifier.verifyAndDecodeTransaction(SIGNED_TX_INFO)) + .thenReturn(new JWSTransactionDecodedPayload().productId(PRODUCT_ID)); + when(expectedVerifier.verifyAndDecodeRenewalInfo(SIGNED_RENEWAL_INFO)) + .thenReturn(new JWSRenewalInfoDecodedPayload()); + + apiWrapper.verify(environment, new LastTransactionsItem() + .originalTransactionId(ORIGINAL_TX_ID) + .status(Status.ACTIVE) + .signedRenewalInfo(SIGNED_RENEWAL_INFO) + .signedTransactionInfo(SIGNED_TX_INFO)); + + verify(expectedVerifier).verifyAndDecodeTransaction(SIGNED_TX_INFO); + verify(expectedVerifier).verifyAndDecodeRenewalInfo(SIGNED_RENEWAL_INFO); + verifyNoInteractions(unexpectedVerifier); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java index 165985d71..285dc484a 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java @@ -6,19 +6,16 @@ package org.whispersystems.textsecuregcm.subscriptions; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatException; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.apple.itunes.storekit.client.APIError; import com.apple.itunes.storekit.client.APIException; import com.apple.itunes.storekit.client.AppStoreServerAPIClient; import com.apple.itunes.storekit.model.AutoRenewStatus; +import com.apple.itunes.storekit.model.Environment; import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload; import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; import com.apple.itunes.storekit.model.LastTransactionsItem; @@ -28,7 +25,6 @@ import com.apple.itunes.storekit.model.SubscriptionGroupIdentifierItem; import com.apple.itunes.storekit.verification.SignedDataVerifier; import com.apple.itunes.storekit.verification.VerificationException; import java.io.IOException; -import java.io.UncheckedIOException; import java.math.BigDecimal; import java.time.Duration; import java.time.Instant; @@ -57,8 +53,10 @@ class AppleAppStoreManagerTest { @BeforeEach public void setup() { reset(apiClient, signedDataVerifier); - appleAppStoreManager = new AppleAppStoreManager(apiClient, signedDataVerifier, - SUBSCRIPTION_GROUP_ID, Map.of(PRODUCT_ID, LEVEL), null); + appleAppStoreManager = new AppleAppStoreManager(new AppleAppStoreClient(Environment.PRODUCTION, + signedDataVerifier, apiClient, + signedDataVerifier, apiClient, null), + SUBSCRIPTION_GROUP_ID, Map.of(PRODUCT_ID, LEVEL)); } @Test @@ -126,7 +124,8 @@ class AppleAppStoreManagerTest { .status(Status.ACTIVE) .signedRenewalInfo(SIGNED_RENEWAL_INFO) .signedTransactionInfo(product + "_signed_tx")) - .toList())))); + .toList()))) + .environment(Environment.PRODUCTION)); when(signedDataVerifier.verifyAndDecodeRenewalInfo(SIGNED_RENEWAL_INFO)) .thenReturn(new JWSRenewalInfoDecodedPayload() .autoRenewStatus(AutoRenewStatus.ON)); @@ -148,39 +147,6 @@ class AppleAppStoreManagerTest { } - @Test - public void retryEventuallyWorks() throws APIException, IOException, VerificationException, RateLimitExceededException, SubscriptionException { - // Should retry up to 3 times - when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) - .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test")) - .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test")) - .thenReturn(new StatusResponse().data(List.of(new SubscriptionGroupIdentifierItem() - .subscriptionGroupIdentifier(SUBSCRIPTION_GROUP_ID) - .addLastTransactionsItem(new LastTransactionsItem() - .originalTransactionId(ORIGINAL_TX_ID) - .status(Status.ACTIVE) - .signedRenewalInfo(SIGNED_RENEWAL_INFO) - .signedTransactionInfo(SIGNED_TX_INFO))))); - mockDecode(AutoRenewStatus.ON); - final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID); - assertThat(info.status()).isEqualTo(SubscriptionStatus.ACTIVE); - } - - @Test - public void retryEventuallyGivesUp() throws APIException, IOException, VerificationException { - // Should retry up to 3 times - when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) - .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test")); - mockDecode(AutoRenewStatus.ON); - assertThatException() - .isThrownBy(() -> appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID)) - .isInstanceOf(UncheckedIOException.class) - .withRootCauseInstanceOf(APIException.class); - - verify(apiClient, times(3)).getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}); - - } - @Test public void cancelRenewalDisabled() throws APIException, VerificationException, IOException { mockSubscription(Status.ACTIVE, AutoRenewStatus.OFF); @@ -205,13 +171,15 @@ class AppleAppStoreManagerTest { private void mockSubscription(final Status status, final AutoRenewStatus autoRenewStatus) throws APIException, IOException, VerificationException { when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) - .thenReturn(new StatusResponse().data(List.of(new SubscriptionGroupIdentifierItem() + .thenReturn(new StatusResponse() + .data(List.of(new SubscriptionGroupIdentifierItem() .subscriptionGroupIdentifier(SUBSCRIPTION_GROUP_ID) .addLastTransactionsItem(new LastTransactionsItem() .originalTransactionId(ORIGINAL_TX_ID) .status(status) .signedRenewalInfo(SIGNED_RENEWAL_INFO) - .signedTransactionInfo(SIGNED_TX_INFO))))); + .signedTransactionInfo(SIGNED_TX_INFO)))) + .environment(Environment.PRODUCTION)); mockDecode(autoRenewStatus); }