Add LoginPurchaseController

This commit is contained in:
Ravi Khadiwala
2026-08-07 14:39:13 -05:00
committed by ravi-signal
parent 7a34493d53
commit d6b80dfd16
15 changed files with 469 additions and 6 deletions
+5
View File
@@ -385,6 +385,11 @@ subscription: # configuration for Stripe subscriptions
STRIPE: price_example # stripe Price ID
BRAINTREE: plan_example # braintree Plan ID
loginPurchase:
level: 300
playProductId: login_example
appStoreProductId: login_example
oneTimeDonations:
sepaMaximumEuros: '10000'
boost:
@@ -44,6 +44,7 @@ import org.whispersystems.textsecuregcm.configuration.HlrLookupConfiguration;
import org.whispersystems.textsecuregcm.configuration.IdlePrimaryDeviceReminderConfiguration;
import org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration;
import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration;
import org.whispersystems.textsecuregcm.configuration.MessageByteLimitCardinalityEstimatorConfiguration;
import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
@@ -266,6 +267,11 @@ public class WhisperServerConfiguration extends Configuration {
@NotNull
private OneTimeDonationConfiguration oneTimeDonations;
@Valid
@JsonProperty
@NotNull
private LoginPurchaseConfiguration loginPurchase;
@Valid
@JsonProperty
@NotNull
@@ -532,6 +538,10 @@ public class WhisperServerConfiguration extends Configuration {
return oneTimeDonations;
}
public LoginPurchaseConfiguration getLoginPurchase() {
return loginPurchase;
}
public PagedSingleUseKEMPreKeyStoreConfiguration getPagedSingleUseKEMPreKeyStore() {
return pagedSingleUseKEMPreKeyStore;
}
@@ -130,6 +130,7 @@ import org.whispersystems.textsecuregcm.controllers.DonationController;
import org.whispersystems.textsecuregcm.controllers.KeepAliveController;
import org.whispersystems.textsecuregcm.controllers.KeyTransparencyController;
import org.whispersystems.textsecuregcm.controllers.KeysController;
import org.whispersystems.textsecuregcm.controllers.LoginPurchaseController;
import org.whispersystems.textsecuregcm.controllers.MessageController;
import org.whispersystems.textsecuregcm.controllers.OneTimeDonationController;
import org.whispersystems.textsecuregcm.controllers.PaymentsController;
@@ -300,7 +301,9 @@ import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
import org.whispersystems.textsecuregcm.subscriptions.LoginPurchaseManager;
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.telephony.CarrierDataProvider;
import org.whispersystems.textsecuregcm.telephony.hlrlookup.HlrLookupCarrierDataProvider;
@@ -987,6 +990,15 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
List.of(stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager),
zkReceiptOperations, issuedReceiptsManager);
final LoginPurchaseManager loginPurchaseManager = new LoginPurchaseManager(
Map.of(
PaymentProvider.APPLE_APP_STORE, appleAppStoreManager,
PaymentProvider.GOOGLE_PLAY_BILLING, googlePlayBillingManager),
issuedReceiptsManager,
zkReceiptOperations,
config.getLoginPurchase().level(),
clock);
final List<SpamFilter> spamFilters = ServiceLoader.load(SpamFilter.class)
.stream()
.map(ServiceLoader.Provider::get)
@@ -1109,7 +1121,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new ChallengeGrpcService(accountsManager, rateLimitChallengeManager, challengeConstraintChecker),
new DonationsGrpcService(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(), ReceiptCredentialPresentation::new, donationPermitsManager, rateLimiters),
new ProductConfigurationGrpcService(config.getSubscription(), config.getOneTimeDonations(),
List.of(stripeManager, braintreeManager), config.getBackupConfiguration().maxTotalMediaSize()),
config.getLoginPurchase(), List.of(stripeManager, braintreeManager),
config.getBackupConfiguration().maxTotalMediaSize()),
new RemoteConfigurationGrpcService(remoteConfigsManager, profileBadgeConverter,
config.getBadges().getBadges().stream()
.map(BadgeConfiguration::getId)
@@ -1284,12 +1297,14 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
phoneNumberIdentifiers, rateLimiters, accountsManager, carrierDataProvider, registrationFraudChecker,
dynamicConfigurationManager, experimentEnrollmentManager, clock),
new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager,
config.getLoginPurchase(), subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager,
appleAppStoreManager,
profileBadgeConverter, bankMandateTranslator, donationPermitsManager,
config.getBackupConfiguration().maxTotalMediaSize()),
new OneTimeDonationController(clock, config.getOneTimeDonations(), stripeManager, braintreeManager,
payPalDonationsTranslator, zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager,
donationPermitsManager)
donationPermitsManager),
new LoginPurchaseController(loginPurchaseManager, dynamicConfigurationManager)
);
for (Object controller : commonControllers) {
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.time.Duration;
/// Configuration for one-time Signal Login purchases
///
/// @param level the receipt level that identifies a purchase as a Signal Login.
/// @param playProductId the Google Play Billing productId clients should purchase to obtain a Signal Login
/// @param appStoreProductId the App Store productId clients should purchase to obtain a Signal Login
public record LoginPurchaseConfiguration(
@Positive long level,
@NotEmpty String playProductId,
@NotEmpty String appStoreProductId) {}
@@ -85,6 +85,10 @@ public class DynamicConfiguration {
@Valid
private DynamicTurnConfiguration turn = new DynamicTurnConfiguration();
@JsonProperty
@Valid
private DynamicLoginPurchaseConfiguration loginPurchase = new DynamicLoginPurchaseConfiguration(false);
public Optional<DynamicExperimentEnrollmentConfiguration> getExperimentEnrollmentConfiguration(
final String experimentName) {
return Optional.ofNullable(experiments.get(experimentName));
@@ -154,4 +158,8 @@ public class DynamicConfiguration {
public DynamicTurnConfiguration getTurnConfiguration() {
return turn;
}
public DynamicLoginPurchaseConfiguration getLoginPurchaseConfiguration() {
return loginPurchase;
}
}
@@ -0,0 +1,8 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration.dynamic;
/// @param enabled whether clients may exchange a one-time purchase for a Signal Login receipt credential
public record DynamicLoginPurchaseConfiguration(boolean enabled) {}
@@ -0,0 +1,127 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import io.dropwizard.auth.Auth;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.util.Optional;
import org.glassfish.jersey.server.ManagedAsync;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.subscriptions.LoginPurchaseManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptAlreadyRedeemedException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException;
@Path("/v1/login-purchase")
@io.swagger.v3.oas.annotations.tags.Tag(name = "LoginPurchase")
public class LoginPurchaseController {
private final LoginPurchaseManager loginPurchaseManager;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
public LoginPurchaseController(
final LoginPurchaseManager loginPurchaseManager,
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
this.loginPurchaseManager = loginPurchaseManager;
this.dynamicConfigurationManager = dynamicConfigurationManager;
}
public record CreateLoginReceiptCredentialRequest(
@NotNull String purchaseIdentifier,
@NotNull byte[] receiptCredentialRequest,
@NotNull PaymentProvider paymentProvider) {
}
public record CreateLoginReceiptCredentialResponse(byte[] receiptCredentialResponse) {
}
@POST
@Path("/receipt_credentials")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a receipt credential for a completed one-time login purchase",
description = """
Verify a completed one-time purchase with the payment provider and issue a receipt credential that can be
redeemed for a login.
This endpoint must be called on an unauthenticated connection. Retries for the same purchaseIdentifier MUST
use the same receiptCredentialRequest.
""")
@ApiResponse(responseCode = "200", description = "Successfully created receipt",
content = @Content(schema = @Schema(implementation = CreateLoginReceiptCredentialResponse.class)))
@ApiResponse(responseCode = "204", description = "The purchase is still pending with the payment provider. The client may retry later.")
@ApiResponse(responseCode = "400", description = """
Invalid request: malformed request, failed zkgroup verification, unsupported paymentProvider, the provided
purchase identifier not for a Signal Login, or login purchases are not currently enabled.
""")
@ApiResponse(responseCode = "402", description = "The purchase did not complete successfully. The body may include ChargeFailure details.",
content = @Content(schema = @Schema(
nullable = true,
implementation = SubscriptionExceptionMapper.ChargeFailureResponse.class)))
@ApiResponse(responseCode = "403", description = "The request was made on an authenticated channel")
@ApiResponse(responseCode = "404", description = "The payment provider has no purchase with the provided purchaseIdentifier")
@ApiResponse(responseCode = "409", description = "The purchase was already redeemed for a receipt credential, but with a different receipt credential request")
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
name = "Retry-After",
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
@ManagedAsync
public Response createLoginReceiptCredential(
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final CreateLoginReceiptCredentialRequest request)
throws IOException, SubscriptionPaymentRequiredException, SubscriptionInvalidArgumentsException, SubscriptionNotFoundException, RateLimitExceededException, SubscriptionReceiptAlreadyRedeemedException {
if (!dynamicConfigurationManager.getConfiguration().getLoginPurchaseConfiguration().enabled()) {
throw new BadRequestException("login purchases are not enabled");
}
if (authenticatedAccount.isPresent()) {
throw new ForbiddenException("must not use authenticated connection for login purchase operations");
}
final ReceiptCredentialRequest receiptCredentialRequest;
try {
receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest);
} catch (final InvalidInputException e) {
throw new BadRequestException("invalid receipt credential request", e);
}
try {
final ReceiptCredentialResponse receiptCredentialResponse = loginPurchaseManager.generateReceipt(
request.paymentProvider, request.purchaseIdentifier, receiptCredentialRequest);
return Response.ok(
new CreateLoginReceiptCredentialResponse(receiptCredentialResponse.serialize()))
.build();
} catch (SubscriptionReceiptRequestedForOpenPaymentException e) {
return Response.noContent().build();
} catch (VerificationFailedException e) {
throw new BadRequestException("receipt credential request failed verification", e);
}
}
}
@@ -66,6 +66,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.DonationPermitHeader;
import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration;
@@ -106,6 +107,7 @@ public class SubscriptionController {
private final Clock clock;
private final SubscriptionConfiguration subscriptionConfiguration;
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
private final LoginPurchaseConfiguration loginPurchaseConfiguration;
private final SubscriptionManager subscriptionManager;
private final StripeManager stripeManager;
private final BraintreeManager braintreeManager;
@@ -124,6 +126,7 @@ public class SubscriptionController {
Clock clock,
SubscriptionConfiguration subscriptionConfiguration,
OneTimeDonationConfiguration oneTimeDonationConfiguration,
LoginPurchaseConfiguration loginPurchaseConfiguration,
SubscriptionManager subscriptionManager,
StripeManager stripeManager,
BraintreeManager braintreeManager,
@@ -137,6 +140,7 @@ public class SubscriptionController {
this.clock = Objects.requireNonNull(clock);
this.subscriptionConfiguration = Objects.requireNonNull(subscriptionConfiguration);
this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
this.loginPurchaseConfiguration = Objects.requireNonNull(loginPurchaseConfiguration);
this.stripeManager = Objects.requireNonNull(stripeManager);
this.braintreeManager = Objects.requireNonNull(braintreeManager);
this.googlePlayBillingManager = Objects.requireNonNull(googlePlayBillingManager);
@@ -167,6 +171,8 @@ public class SubscriptionController {
buildDonationLevelsConfiguration(subscriptionConfiguration, oneTimeDonationConfiguration, badgeTranslator,
acceptableLanguages),
new BackupConfiguration(backupLevels, subscriptionConfiguration.getbackupFreeTierMediaDuration().toDays()),
new LoginConfiguration(loginPurchaseConfiguration.level(), loginPurchaseConfiguration.playProductId(),
loginPurchaseConfiguration.appStoreProductId()),
oneTimeDonationConfiguration.sepaMaximumEuros());
}
@@ -532,9 +538,20 @@ public class SubscriptionController {
Map<Long, LevelConfiguration> levels,
@Schema(description = "Backup specific configuration")
BackupConfiguration backup,
@Schema(description = "Signal Login specific configuration")
LoginConfiguration login,
@Schema(description = "The maximum value of a one-time donation SEPA transaction")
BigDecimal sepaMaximumEuros) {}
@Schema(description = "Configuration for one-time Signal Login purchases")
public record LoginConfiguration(
@Schema(description = "The receipt level associated with a Signal Login purchase")
long level,
@Schema(description = "The play billing productID associated with a Signal Login purchase")
String playProductId,
@Schema(description = "The App Store productID associated with a Signal Login purchase")
String appStoreProductId) {}
public record BackupConfiguration(
@Schema(description = "A map of numeric backup level IDs to level-specific backup configuration")
Map<String, BackupLevelConfiguration> levels,
@@ -12,7 +12,9 @@ import org.signal.chat.purchase.CurrencyConfiguration;
import org.signal.chat.purchase.GetConfigurationRequest;
import org.signal.chat.purchase.GetConfigurationResponse;
import org.signal.chat.purchase.LevelConfiguration;
import org.signal.chat.purchase.LoginConfiguration;
import org.signal.chat.purchase.SimpleProductConfigurationGrpc;
import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
@@ -24,6 +26,7 @@ public class ProductConfigurationGrpcService extends SimpleProductConfigurationG
public ProductConfigurationGrpcService(
final SubscriptionConfiguration subscriptionConfiguration,
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
final LoginPurchaseConfiguration loginPurchaseConfiguration,
List<CustomerAwareSubscriptionPaymentProcessor> paymentProcessors,
final long backupMediaStorageAllowanceBytes) {
this.configurationResponse = GetConfigurationResponse.newBuilder()
@@ -31,6 +34,7 @@ public class ProductConfigurationGrpcService extends SimpleProductConfigurationG
.setSepaMaximumEuros(oneTimeDonationConfiguration.sepaMaximumEuros().toString())
.putAllCurrencies(buildCurrencyConfigurations(subscriptionConfiguration, oneTimeDonationConfiguration, paymentProcessors))
.putAllBadgeLevels(buildLevelConfigurations(subscriptionConfiguration, oneTimeDonationConfiguration))
.setLogin(buildLoginConfiguration(loginPurchaseConfiguration))
.build();
}
@@ -85,6 +89,15 @@ public class ProductConfigurationGrpcService extends SimpleProductConfigurationG
.build();
}
private static LoginConfiguration buildLoginConfiguration(
final LoginPurchaseConfiguration loginPurchaseConfiguration) {
return LoginConfiguration.newBuilder()
.setLevel(loginPurchaseConfiguration.level())
.setPlayProductId(loginPurchaseConfiguration.playProductId())
.setAppStoreProductId(loginPurchaseConfiguration.appStoreProductId())
.build();
}
private static CurrencyConfiguration toProtoCurrencyConfiguration(
final String currency,
final org.whispersystems.textsecuregcm.subscriptions.CurrencyConfiguration config) {
@@ -4,7 +4,6 @@
*/
package org.whispersystems.textsecuregcm.subscriptions;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import java.io.IOException;
import java.util.Optional;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
@@ -33,6 +33,8 @@ message GetConfigurationResponse {
BackupConfiguration backup = 3;
// Maximum value of a one-time SEPA donation
string sepa_maximum_euros = 4;
// Configuration for one-time Signal Login purchases
LoginConfiguration login = 5;
}
message AmountList {
@@ -81,3 +83,13 @@ message BackupConfiguration {
// The number of days of media a free tier backup user gets
uint64 free_tier_media_days = 2;
}
// Configuration for one-time Signal Login purchases
message LoginConfiguration {
// The receipt level associated with a Signal Login purchase
uint64 level = 1;
// The play billing productID associated with a Signal Login purchase
string play_product_id = 2;
// The App Store productID associated with a Signal Login purchase
string app_store_product_id = 3;
}
@@ -0,0 +1,209 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import io.dropwizard.auth.AuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.core.Response;
import java.util.stream.Stream;
import org.glassfish.jersey.server.ServerProperties;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicLoginPurchaseConfiguration;
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.LoginPurchaseManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionChargeFailurePaymentRequiredException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptAlreadyRedeemedException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
@ExtendWith(DropwizardExtensionsSupport.class)
class LoginPurchaseControllerTest {
private static final String PURCHASE_ID = "purchaseId";
private static final ServerSecretParams SERVER_SECRET_PARAMS = ServerSecretParams.generate();
private static final ClientZkReceiptOperations CLIENT_ZK_OPS =
new ClientZkReceiptOperations(SERVER_SECRET_PARAMS.getPublicParams());
private static final ServerZkReceiptOperations SERVER_ZK_OPS =
new ServerZkReceiptOperations(SERVER_SECRET_PARAMS);
private static final LoginPurchaseManager LOGIN_PURCHASE_MANAGER = mock(LoginPurchaseManager.class);
private static final DynamicLoginPurchaseConfiguration ENABLED = new DynamicLoginPurchaseConfiguration(true);
private static final DynamicLoginPurchaseConfiguration DISABLED = new DynamicLoginPurchaseConfiguration(false);
@SuppressWarnings("unchecked")
private static final DynamicConfigurationManager<DynamicConfiguration> DYNAMIC_CONFIGURATION_MANAGER =
mock(DynamicConfigurationManager.class);
private static final DynamicConfiguration DYNAMIC_CONFIGURATION = mock(DynamicConfiguration.class);
private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
.addProvider(AuthHelper.getAuthFilter())
.addProvider(CompletionExceptionMapper.class)
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))
.addProvider(SubscriptionExceptionMapper.class)
.addProvider(RateLimitExceededExceptionMapper.class)
.setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new LoginPurchaseController(LOGIN_PURCHASE_MANAGER, DYNAMIC_CONFIGURATION_MANAGER))
.build();
private ReceiptCredentialRequestContext receiptCredentialRequestContext;
@BeforeEach
void setUp() throws InvalidInputException, VerificationFailedException {
reset(LOGIN_PURCHASE_MANAGER, DYNAMIC_CONFIGURATION_MANAGER, DYNAMIC_CONFIGURATION);
when(DYNAMIC_CONFIGURATION_MANAGER.getConfiguration()).thenReturn(DYNAMIC_CONFIGURATION);
when(DYNAMIC_CONFIGURATION.getLoginPurchaseConfiguration()).thenReturn(ENABLED);
receiptCredentialRequestContext = CLIENT_ZK_OPS.createReceiptCredentialRequestContext(
new ReceiptSerial(TestRandomUtil.nextBytes(ReceiptSerial.SIZE)));
}
@Test
void createReceiptCredential() throws Exception {
final ReceiptCredentialResponse receiptCredentialResponse =
SERVER_ZK_OPS.issueReceiptCredential(receiptCredentialRequestContext.getRequest(), 0L, 200L);
when(LOGIN_PURCHASE_MANAGER.generateReceipt(any(), any(), any())).thenReturn(receiptCredentialResponse);
try (final Response response = RESOURCE_EXTENSION
.target("/v1/login-purchase/receipt_credentials")
.request()
.post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest(
PURCHASE_ID,
receiptCredentialRequestContext.getRequest().serialize(),
PaymentProvider.APPLE_APP_STORE)))) {
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(LoginPurchaseController.CreateLoginReceiptCredentialResponse.class)
.receiptCredentialResponse()).isEqualTo(receiptCredentialResponse.serialize());
}
verify(LOGIN_PURCHASE_MANAGER).generateReceipt(
eq(PaymentProvider.APPLE_APP_STORE),
eq(PURCHASE_ID),
any(ReceiptCredentialRequest.class));
}
@Test
void createReceiptCredentialAuthenticated() {
try (final Response response = RESOURCE_EXTENSION.target("/v1/login-purchase/receipt_credentials")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest(
PURCHASE_ID,
receiptCredentialRequestContext.getRequest().serialize(),
PaymentProvider.APPLE_APP_STORE)))) {
assertThat(response.getStatus()).isEqualTo(403);
}
verifyNoInteractions(LOGIN_PURCHASE_MANAGER);
}
@Test
void createReceiptCredentialNotEnabled() {
when(DYNAMIC_CONFIGURATION.getLoginPurchaseConfiguration()).thenReturn(DISABLED);
try (final Response response = RESOURCE_EXTENSION.target("/v1/login-purchase/receipt_credentials")
.request()
.post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest(
PURCHASE_ID,
receiptCredentialRequestContext.getRequest().serialize(),
PaymentProvider.APPLE_APP_STORE)))) {
assertThat(response.getStatus()).isEqualTo(400);
}
verifyNoInteractions(LOGIN_PURCHASE_MANAGER);
}
static Stream<Arguments> createReceiptCredentialErrors() {
return Stream.of(
Arguments.of(new SubscriptionReceiptRequestedForOpenPaymentException(), 204),
Arguments.of(new SubscriptionPaymentRequiredException(), 402),
Arguments.of(new SubscriptionNotFoundException(), 404),
Arguments.of(new SubscriptionInvalidArgumentsException("test"), 400),
Arguments.of(new SubscriptionReceiptAlreadyRedeemedException(), 409),
Arguments.of(new VerificationFailedException(), 400),
Arguments.of(new RateLimitExceededException(null), 429));
}
@ParameterizedTest
@MethodSource
void createReceiptCredentialErrors(final Exception exception, final int expectedStatus) throws Exception {
when(LOGIN_PURCHASE_MANAGER.generateReceipt(any(), any(), any())).thenThrow(exception);
try (final Response response = RESOURCE_EXTENSION.target("/v1/login-purchase/receipt_credentials")
.request()
.post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest(
PURCHASE_ID,
receiptCredentialRequestContext.getRequest().serialize(),
PaymentProvider.APPLE_APP_STORE)))) {
assertThat(response.getStatus()).isEqualTo(expectedStatus);
}
}
@Test
void createReceiptCredentialPaymentRequiredWithChargeFailure() throws Exception {
final ChargeFailure chargeFailure =
new ChargeFailure("generic_decline", "some failure message", null, null, null);
when(LOGIN_PURCHASE_MANAGER.generateReceipt(any(), any(), any()))
.thenThrow(new SubscriptionChargeFailurePaymentRequiredException(PaymentProvider.APPLE_APP_STORE, chargeFailure));
try (final Response response = RESOURCE_EXTENSION.target("/v1/login-purchase/receipt_credentials")
.request()
.post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest(
PURCHASE_ID,
receiptCredentialRequestContext.getRequest().serialize(),
PaymentProvider.APPLE_APP_STORE)))) {
final SubscriptionExceptionMapper.ChargeFailureResponse failureResponse =
response.readEntity(SubscriptionExceptionMapper.ChargeFailureResponse.class);
assertThat(failureResponse.chargeFailure()).isEqualTo(chargeFailure);
}
}
}
@@ -60,6 +60,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
import org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetBankMandateResponse;
import org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetSubscriptionConfigurationResponse;
@@ -109,8 +110,10 @@ class SubscriptionControllerTest extends AbstractV1SubscriptionControllerTest {
private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);
private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class);
private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class);
private static final LoginPurchaseConfiguration LOGIN_PURCHASE_CONFIG =
new LoginPurchaseConfiguration(300L, "testLoginPlayProductId", "testLoginAppStoreProductId");
private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK,
SUBSCRIPTION_CONFIG, ONETIME_CONFIG,
SUBSCRIPTION_CONFIG, ONETIME_CONFIG, LOGIN_PURCHASE_CONFIG,
new SubscriptionManager(SUBSCRIPTIONS, List.of(STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, APPSTORE_MANAGER),
ZK_OPS, ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, APPSTORE_MANAGER,
BADGE_TRANSLATOR, BANK_MANDATE_TRANSLATOR, DONATION_PERMITS_MANAGER, MAX_TOTAL_BACKUP_MEDIA_BYTES);
@@ -1074,6 +1077,10 @@ class SubscriptionControllerTest extends AbstractV1SubscriptionControllerTest {
});
assertThat(response.backup().freeTierMediaDays()).isEqualTo(30);
assertThat(response.login().level()).isEqualTo(LOGIN_PURCHASE_CONFIG.level());
assertThat(response.login().playProductId()).isEqualTo(LOGIN_PURCHASE_CONFIG.playProductId());
assertThat(response.login().appStoreProductId()).isEqualTo(LOGIN_PURCHASE_CONFIG.appStoreProductId());
// check the badge vs purchasable badge fields
// subscription levels are Badge, while one-time levels are PurchasableBadge, which adds `duration`
Map<String, Object> genericResponse = RESOURCE_EXTENSION.target("/v1/subscription/configuration")
@@ -22,6 +22,7 @@ import org.signal.chat.purchase.GetConfigurationRequest;
import org.signal.chat.purchase.GetConfigurationResponse;
import org.signal.chat.purchase.PaymentMethod;
import org.signal.chat.purchase.ProductConfigurationGrpc;
import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
@@ -37,6 +38,9 @@ public class ProductConfigurationGrpcServiceTest extends
private final OneTimeDonationConfiguration oneTimeDonationConfiguration =
SubscriptionConfigTestHelper.getOneTimeConfig();
private static final LoginPurchaseConfiguration LOGIN_PURCHASE_CONFIGURATION =
new LoginPurchaseConfiguration(300L, "testLoginPlayProductId", "testLoginAppStoreProductId");
@Mock
private StripeManager stripeManager;
@@ -65,7 +69,7 @@ public class ProductConfigurationGrpcServiceTest extends
return new ProductConfigurationGrpcService(subscriptionConfiguration, oneTimeDonationConfiguration,
List.of(stripeManager, braintreeManager), 1234L);
LOGIN_PURCHASE_CONFIGURATION, List.of(stripeManager, braintreeManager), 1234L);
}
@Test
@@ -127,6 +131,10 @@ public class ProductConfigurationGrpcServiceTest extends
assertTrue(configuration.getBadgeLevelsOrThrow(1L).getBadgeDurationSeconds() > 0);
assertEquals("GIFT", configuration.getBadgeLevelsOrThrow(100L).getBadgeId());
assertTrue(configuration.getBadgeLevelsOrThrow(100L).getBadgeDurationSeconds() > 0);
assertEquals(LOGIN_PURCHASE_CONFIGURATION.level(), configuration.getLogin().getLevel());
assertEquals(LOGIN_PURCHASE_CONFIGURATION.playProductId(), configuration.getLogin().getPlayProductId());
assertEquals(LOGIN_PURCHASE_CONFIGURATION.appStoreProductId(), configuration.getLogin().getAppStoreProductId());
}
@@ -397,6 +397,11 @@ subscription: # configuration for Stripe subscriptions
STRIPE: price_example # stripe Price ID
BRAINTREE: plan_example # braintree Plan ID
loginPurchase:
level: 300
playProductId: login_example
appStoreProductId: login_example
oneTimeDonations:
sepaMaximumEuros: '10000'
boost: