// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.payments;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Parcelable;
import android.text.TextUtils;
import androidx.test.filters.MediumTest;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.runner.RunWith;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.Feature;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.components.payments.Address;
import org.chromium.components.payments.ErrorStrings;
import org.chromium.components.payments.IPaymentDetailsUpdateService;
import org.chromium.components.payments.IPaymentDetailsUpdateServiceCallback;
import org.chromium.components.payments.PaymentDetailsUpdateService;
import org.chromium.components.payments.PaymentDetailsUpdateServiceHelper;
import org.chromium.components.payments.PaymentRequestUpdateEventListener;
import org.chromium.components.payments.intent.WebPaymentIntentHelperType.PaymentCurrencyAmount;
import org.chromium.components.payments.intent.WebPaymentIntentHelperType.PaymentHandlerMethodData;
import org.chromium.components.payments.intent.WebPaymentIntentHelperType.PaymentRequestDetailsUpdate;
import org.chromium.components.payments.intent.WebPaymentIntentHelperType.PaymentShippingOption;
import org.chromium.payments.mojom.PaymentAddress;
import java.util.ArrayList;
import java.util.List;
/** Tests for PaymentDetailsUpdateServiceHelper. */
@RunWith(ChromeJUnit4ClassRunner.class)
@CommandLineFlags.Add({ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE})
public class PaymentDetailsUpdateServiceHelperTest {
private static final int DECODER_STARTUP_TIMEOUT_IN_MS = 10000;
@Rule
public ChromeTabbedActivityTestRule mActivityTestRule = new ChromeTabbedActivityTestRule();
@Rule public ExpectedException thrown = ExpectedException.none();
/** Simulates a package manager in memory. */
private final MockPackageManagerDelegate mPackageManager = new MockPackageManagerDelegate();
private Context mContext;
private Bundle defaultAddressBundle() {
Bundle bundle = new Bundle();
bundle.putString(Address.EXTRA_ADDRESS_COUNTRY, "CA");
String[] addressLine = {"111 Richmond Street West"};
bundle.putStringArray(Address.EXTRA_ADDRESS_LINES, addressLine);
bundle.putString(Address.EXTRA_ADDRESS_REGION, "Ontario");
bundle.putString(Address.EXTRA_ADDRESS_CITY, "Toronto");
bundle.putString(Address.EXTRA_ADDRESS_POSTAL_CODE, "M5H2G4");
bundle.putString(Address.EXTRA_ADDRESS_RECIPIENT, "John Smith");
bundle.putString(Address.EXTRA_ADDRESS_PHONE, "4169158200");
return bundle;
}
private Bundle defaultMethodDataBundle() {
Bundle bundle = new Bundle();
bundle.putString(PaymentHandlerMethodData.EXTRA_METHOD_NAME, "method-name");
bundle.putString(
PaymentHandlerMethodData.EXTRA_STRINGIFIED_DETAILS, "{\"key\": \"value\"}");
return bundle;
}
private boolean mBound;
private IPaymentDetailsUpdateService mIPaymentDetailsUpdateService;
private ServiceConnection mConnection =
new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName className, IBinder service) {
mIPaymentDetailsUpdateService =
IPaymentDetailsUpdateService.Stub.asInterface(service);
mBound = true;
}
@Override
public void onServiceDisconnected(ComponentName className) {
mIPaymentDetailsUpdateService = null;
mBound = false;
}
};
@Before
public void setUp() throws Throwable {
mActivityTestRule.startMainActivityOnBlankPage();
mContext = mActivityTestRule.getActivity();
}
private void installPaymentApp() {
mPackageManager.installPaymentApp(
"BobPay",
/* packageName= */ "com.bobpay",
null /* no metadata */,
/* signature= */ "01");
mPackageManager.setInvokedAppPackageName(/* packageName= */ "com.bobpay");
}
private void installAndInvokePaymentApp() throws Throwable {
installPaymentApp();
ThreadUtils.runOnUiThreadBlocking(
() -> {
PaymentDetailsUpdateServiceHelper.getInstance()
.initialize(
mPackageManager,
/* packageName= */ "com.bobpay",
mUpdateListener);
});
}
private void updateWithDefaultDetails() throws Throwable {
PaymentCurrencyAmount total =
new PaymentCurrencyAmount(/* currency= */ "CAD", /* value= */ "10.00");
// Populate shipping options.
List<PaymentShippingOption> shippingOptions = new ArrayList<PaymentShippingOption>();
shippingOptions.add(
new PaymentShippingOption(
"shippingId",
"Free shipping",
new PaymentCurrencyAmount("CAD", "0.00"),
/* selected= */ true));
// Populate address errors.
Bundle bundledShippingAddressErrors = new Bundle();
bundledShippingAddressErrors.putString("addressLine", "invalid address line");
bundledShippingAddressErrors.putString("city", "invalid city");
bundledShippingAddressErrors.putString("countryCode", "invalid country code");
bundledShippingAddressErrors.putString("dependentLocality", "invalid dependent locality");
bundledShippingAddressErrors.putString("organization", "invalid organization");
bundledShippingAddressErrors.putString("phone", "invalid phone");
bundledShippingAddressErrors.putString("postalCode", "invalid postal code");
bundledShippingAddressErrors.putString("recipient", "invalid recipient");
bundledShippingAddressErrors.putString("region", "invalid region");
bundledShippingAddressErrors.putString("sortingCode", "invalid sorting code");
PaymentRequestDetailsUpdate response =
new PaymentRequestDetailsUpdate(
total,
shippingOptions,
/* error= */ "error message",
/* pstringifiedPaymentMethodErrors= */ "stringified payment method",
bundledShippingAddressErrors);
ThreadUtils.runOnUiThreadBlocking(
() -> {
PaymentDetailsUpdateServiceHelper.getInstance().updateWith(response);
});
}
private void onPaymentDetailsNotUpdated() throws Throwable {
ThreadUtils.runOnUiThreadBlocking(
() -> {
PaymentDetailsUpdateServiceHelper.getInstance().onPaymentDetailsNotUpdated();
});
}
private void verifyUpdatedDefaultDetails() {
Bundle total = mUpdatedPaymentDetails.getBundle(PaymentRequestDetailsUpdate.EXTRA_TOTAL);
Assert.assertEquals("CAD", total.getString(PaymentCurrencyAmount.EXTRA_CURRENCY));
Assert.assertEquals("10.00", total.getString(PaymentCurrencyAmount.EXTRA_VALUE));
// Validate shipping options
Parcelable[] shippingOptions =
mUpdatedPaymentDetails.getParcelableArray(
PaymentRequestDetailsUpdate.EXTRA_SHIPPING_OPTIONS);
Assert.assertEquals(1, shippingOptions.length);
Bundle shippingOption = (Bundle) shippingOptions[0];
Assert.assertEquals(
"shippingId",
shippingOption.getString(PaymentShippingOption.EXTRA_SHIPPING_OPTION_ID));
Assert.assertEquals(
"Free shipping",
shippingOption.getString(PaymentShippingOption.EXTRA_SHIPPING_OPTION_LABEL));
Bundle amount =
shippingOption.getBundle(PaymentShippingOption.EXTRA_SHIPPING_OPTION_AMOUNT);
Assert.assertEquals("CAD", amount.getString(PaymentCurrencyAmount.EXTRA_CURRENCY));
Assert.assertEquals("0.00", amount.getString(PaymentCurrencyAmount.EXTRA_VALUE));
Assert.assertTrue(
shippingOption.getBoolean(PaymentShippingOption.EXTRA_SHIPPING_OPTION_SELECTED));
Assert.assertEquals(
"error message",
mUpdatedPaymentDetails.getString(PaymentRequestDetailsUpdate.EXTRA_ERROR_MESSAGE));
Assert.assertEquals(
"stringified payment method",
mUpdatedPaymentDetails.getString(
PaymentRequestDetailsUpdate.EXTRA_STRINGIFIED_PAYMENT_METHOD_ERRORS));
// Validate address errors
Bundle addressError =
mUpdatedPaymentDetails.getBundle(PaymentRequestDetailsUpdate.EXTRA_ADDRESS_ERRORS);
Assert.assertEquals("invalid address line", addressError.getString("addressLine"));
Assert.assertEquals("invalid city", addressError.getString("city"));
Assert.assertEquals("invalid country code", addressError.getString("countryCode"));
Assert.assertEquals(
"invalid dependent locality", addressError.getString("dependentLocality"));
Assert.assertEquals("invalid organization", addressError.getString("organization"));
Assert.assertEquals("invalid phone", addressError.getString("phone"));
Assert.assertEquals("invalid postal code", addressError.getString("postalCode"));
Assert.assertEquals("invalid recipient", addressError.getString("recipient"));
Assert.assertEquals("invalid region", addressError.getString("region"));
Assert.assertEquals("invalid sorting code", addressError.getString("sortingCode"));
}
private void startPaymentDetailsUpdateService() {
Intent intent =
new Intent(
/*ContextUtils.getApplicationContext()*/ mContext,
PaymentDetailsUpdateService.class);
intent.setAction(IPaymentDetailsUpdateService.class.getName());
mContext.bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
CriteriaHelper.pollUiThread(
() -> {
return mBound;
},
DECODER_STARTUP_TIMEOUT_IN_MS,
CriteriaHelper.DEFAULT_POLLING_INTERVAL);
}
private boolean mMethodChangeListenerNotified;
private boolean mShippingOptionChangeListenerNotified;
private boolean mShippingAddressChangeListenerNotified;
private PaymentRequestUpdateEventListener mUpdateListener =
new FakePaymentRequestUpdateEventListener();
private class FakePaymentRequestUpdateEventListener
implements PaymentRequestUpdateEventListener {
@Override
public boolean changePaymentMethodFromInvokedApp(
String methodName, String stringifiedDetails) {
Assert.assertFalse(TextUtils.isEmpty(methodName));
mMethodChangeListenerNotified = true;
return true;
}
@Override
public boolean changeShippingOptionFromInvokedApp(String shippingOptionId) {
Assert.assertFalse(TextUtils.isEmpty(shippingOptionId));
mShippingOptionChangeListenerNotified = true;
return true;
}
@Override
public boolean changeShippingAddressFromInvokedApp(PaymentAddress shippingAddress) {
mShippingAddressChangeListenerNotified = true;
return true;
}
}
private Bundle mUpdatedPaymentDetails;
private boolean mPaymentDetailsDidNotUpdate;
private class PaymentDetailsUpdateServiceCallback
extends IPaymentDetailsUpdateServiceCallback.Stub {
@Override
public void updateWith(Bundle updatedPaymentDetails) {
mUpdatedPaymentDetails = updatedPaymentDetails;
}
@Override
public void paymentDetailsNotUpdated() {
mPaymentDetailsDidNotUpdate = true;
}
}
private String receivedErrorString() {
return mUpdatedPaymentDetails.getString(
PaymentRequestDetailsUpdate.EXTRA_ERROR_MESSAGE, "");
}
private void verifyIsWaitingForPaymentDetailsUpdate(boolean expected) {
ThreadUtils.runOnUiThreadBlocking(
() -> {
Assert.assertEquals(
expected,
PaymentDetailsUpdateServiceHelper.getInstance()
.isWaitingForPaymentDetailsUpdate());
});
}
@Test
@MediumTest
@Feature({"Payments"})
public void testConnectWhenPaymentAppNotInvoked() throws Throwable {
installPaymentApp();
startPaymentDetailsUpdateService();
mIPaymentDetailsUpdateService.changePaymentMethod(
defaultMethodDataBundle(), new PaymentDetailsUpdateServiceCallback());
verifyIsWaitingForPaymentDetailsUpdate(false);
Assert.assertFalse(mMethodChangeListenerNotified);
// An unauthorized app won't get a callback with error.
Assert.assertEquals(null, mUpdatedPaymentDetails);
Assert.assertFalse(mPaymentDetailsDidNotUpdate);
}
@Test
@MediumTest
@Feature({"Payments"})
public void testSuccessfulChangePaymentMethod() throws Throwable {
installAndInvokePaymentApp();
startPaymentDetailsUpdateService();
mIPaymentDetailsUpdateService.changePaymentMethod(
defaultMethodDataBundle(), new PaymentDetailsUpdateServiceCallback());
verifyIsWaitingForPaymentDetailsUpdate(true);
Assert.assertTrue(mMethodChangeListenerNotified);
updateWithDefaultDetails();
verifyUpdatedDefaultDetails();
verifyIsWaitingForPaymentDetailsUpdate(false);
}
@Test
@MediumTest
@Feature({"Payments"})
public void testChangePaymentMethodMissingBundle() throws Throwable {
installAndInvokePaymentApp();
startPaymentDetailsUpdateService();
mIPaymentDetailsUpdateService.changePaymentMethod(
null, new PaymentDetailsUpdateServiceCallback());
verifyIsWaitingForPaymentDetailsUpdate(false);
Assert.assertFalse(mMethodChangeListenerNotified);
Assert.assertEquals(ErrorStrings.METHOD_DATA_REQUIRED, receivedErrorString());
}
@Test
@MediumTest
@Feature({"Payments"})
public void testChangePaymentMethodMissingMethodNameBundle() throws Throwable {
installAndInvokePaymentApp();
startPaymentDetailsUpdateService();
Bundle bundle = new Bundle();
bundle.putString(
PaymentHandlerMethodData.EXTRA_STRINGIFIED_DETAILS, "{\"key\": \"value\"}");
mIPaymentDetailsUpdateService.changePaymentMethod(
bundle, new PaymentDetailsUpdateServiceCallback());
verifyIsWaitingForPaymentDetailsUpdate(false);
Assert.assertFalse(mMethodChangeListenerNotified);
Assert.assertEquals(ErrorStrings.METHOD_NAME_REQUIRED, receivedErrorString());
}
@Test
@MediumTest
@Feature({"Payments"})
public void testSuccessfulChangePaymentMethodWithMissingDetails() throws Throwable {
installAndInvokePaymentApp();
startPaymentDetailsUpdateService();
Bundle bundle = new Bundle();
bundle.putString(PaymentHandlerMethodData.EXTRA_METHOD_NAME, "method-name");
// Skip populating "PaymentHandlerMethodData.EXTRA_STRINGIFIED_DETAILS" to verify that it is
// not a mandatory field.
mIPaymentDetailsUpdateService.changePaymentMethod(
bundle, new PaymentDetailsUpdateServiceCallback());
verifyIsWaitingForPaymentDetailsUpdate(true);
Assert.assertTrue(mMethodChangeListenerNotified);
updateWithDefaultDetails();
verifyUpdatedDefaultDetails();
verifyIsWaitingForPaymentDetailsUpdate(false);
}
@Test
@MediumTest
@Feature({"Payments"})
public void testSuccessfulChangeShippingOption() throws Throwable {
installAndInvokePaymentApp();
startPaymentDetailsUpdateService();
mIPaymentDetailsUpdateService.changeShippingOption(
"shipping option id", new PaymentDetailsUpdateServiceCallback());
verifyIsWaitingForPaymentDetailsUpdate(true);
Assert.assertTrue(mShippingOptionChangeListenerNotified);
updateWithDefaultDetails();
verifyUpdatedDefaultDetails();
verifyIsWaitingForPaymentDetailsUpdate(false);
}
@Test
@MediumTest
@Feature({"Payments"})
public void testChangeShippingOptionWithMissingOptionId() throws Throwable {
installAndInvokePaymentApp();
startPaymentDetailsUpdateService();
mIPaymentDetailsUpdateService.changeShippingOption(
/* shippingOptionId= */ "", new PaymentDetailsUpdateServiceCallback());
verifyIsWaitingForPaymentDetailsUpdate(false);
Assert.assertFalse(mShippingOptionChangeListenerNotified);
Assert.assertEquals(ErrorStrings.SHIPPING_OPTION_ID_REQUIRED, receivedErrorString());
}
@Test
@MediumTest
@Feature({"Payments"})
public void testSuccessfulChangeShippingAddress() throws Throwable {
installAndInvokePaymentApp();
startPaymentDetailsUpdateService();
mIPaymentDetailsUpdateService.changeShippingAddress(
defaultAddressBundle(), new PaymentDetailsUpdateServiceCallback());
verifyIsWaitingForPaymentDetailsUpdate(true);
Assert.assertTrue(mShippingAddressChangeListenerNotified);
updateWithDefaultDetails();
verifyUpdatedDefaultDetails();
verifyIsWaitingForPaymentDetailsUpdate(false);
}
@Test
@MediumTest
@Feature({"Payments"})
public void testChangeShippingAddressWithMissingBundle() throws Throwable {
installAndInvokePaymentApp();
startPaymentDetailsUpdateService();
mIPaymentDetailsUpdateService.changeShippingAddress(
null, new PaymentDetailsUpdateServiceCallback());
verifyIsWaitingForPaymentDetailsUpdate(false);
Assert.assertFalse(mShippingAddressChangeListenerNotified);
Assert.assertEquals(ErrorStrings.SHIPPING_ADDRESS_INVALID, receivedErrorString());
}
@Test
@MediumTest
@Feature({"Payments"})
public void testChangeShippingAddressWithInvalidCountryCode() throws Throwable {
installAndInvokePaymentApp();
startPaymentDetailsUpdateService();
Bundle invalidAddress = defaultAddressBundle();
invalidAddress.putString(Address.EXTRA_ADDRESS_COUNTRY, "");
mIPaymentDetailsUpdateService.changeShippingAddress(
invalidAddress, new PaymentDetailsUpdateServiceCallback());
verifyIsWaitingForPaymentDetailsUpdate(false);
Assert.assertFalse(mShippingAddressChangeListenerNotified);
Assert.assertEquals(ErrorStrings.SHIPPING_ADDRESS_INVALID, receivedErrorString());
}
@Test
@MediumTest
@Feature({"Payments"})
public void testChangeWhileWaitingForPaymentDetailsUpdate() throws Throwable {
installAndInvokePaymentApp();
startPaymentDetailsUpdateService();
mIPaymentDetailsUpdateService.changePaymentMethod(
defaultMethodDataBundle(), new PaymentDetailsUpdateServiceCallback());
verifyIsWaitingForPaymentDetailsUpdate(true);
Assert.assertTrue(mMethodChangeListenerNotified);
// Call changeShippingOption while waiting for updated payment details.
mIPaymentDetailsUpdateService.changeShippingOption(
"shipping option id", new PaymentDetailsUpdateServiceCallback());
verifyIsWaitingForPaymentDetailsUpdate(true);
Assert.assertFalse(mShippingOptionChangeListenerNotified);
Assert.assertEquals(ErrorStrings.INVALID_STATE, receivedErrorString());
}
@Test
@MediumTest
@Feature({"Payments"})
public void testPaymentDetailsNotUpdated() throws Throwable {
installAndInvokePaymentApp();
startPaymentDetailsUpdateService();
mIPaymentDetailsUpdateService.changeShippingOption(
"shipping option id", new PaymentDetailsUpdateServiceCallback());
verifyIsWaitingForPaymentDetailsUpdate(true);
Assert.assertTrue(mShippingOptionChangeListenerNotified);
onPaymentDetailsNotUpdated();
Assert.assertTrue(mPaymentDetailsDidNotUpdate);
verifyIsWaitingForPaymentDetailsUpdate(false);
}
}