diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 5ab9585..c219a63 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -3,14 +3,15 @@ import calendar import datetime from enum import IntEnum -from typing import Dict, List, Optional, Type, TypeVar, Union +from typing import Any, Dict, List, Optional, Type, TypeVar, Union from attr import define -import cattrs import requests import jwt from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization + +from appstoreserverlibrary.models.LibraryUtility import _get_cattrs_converter from .models.CheckTestNotificationResponse import CheckTestNotificationResponse from .models.ConsumptionRequest import ConsumptionRequest @@ -94,11 +95,18 @@ class APIError(IntEnum): @define class APIException(Exception): http_status_code: int - api_error: APIError + api_error: Optional[APIError] + raw_api_error: Optional[int] - def __init__(self, http_status_code: int, api_error: APIError = None): + def __init__(self, http_status_code: int, raw_api_error: Optional[int] = None): self.http_status_code = http_status_code - self.api_error = api_error + self.raw_api_error = raw_api_error + self.api_error = None + try: + if raw_api_error is not None: + self.api_error = APIError(raw_api_error) + except ValueError: + pass class AppStoreServerAPIClient: def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str, environment: Environment): @@ -127,32 +135,35 @@ def _generate_token(self) -> str: def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union[str, List[str]]], body, destination_class: Type[T]) -> T: url = self._base_url + path - json = cattrs.unstructure(body) if body != None else None + c = _get_cattrs_converter(type(body)) if body != None else None + json = c.unstructure(body) if body != None else None headers = { 'User-Agent': "app-store-server-library/python/0.1", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } - response = requests.request(method, url, params=queryParameters, headers=headers, json=json) + response = self._execute_request(method, url, queryParameters, headers, json) if response.status_code >= 200 and response.status_code < 300: if destination_class == None: return + c = _get_cattrs_converter(destination_class) response_body = response.json() - return cattrs.structure(response_body, destination_class) + return c.structure(response_body, destination_class) else: # Best effort parsing of the response body if not 'content-type' in response.headers or response.headers['content-type'] != 'application/json': raise APIException(response.status_code) try: response_body = response.json() - errorValue = APIError(response_body['errorCode']) - raise APIException(response.status_code, errorValue) + raise APIException(response.status_code, response_body['errorCode']) except APIException as e: raise e except Exception: raise APIException(response.status_code) + def _execute_request(self, method: str, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Dict[str, Any]) -> requests.Response: + return requests.request(method, url, params=params, headers=headers, json=json) def extend_renewal_date_for_all_active_subscribers(self, mass_extend_renewal_date_request: MassExtendRenewalDateRequest) -> MassExtendRenewalDateResponse: """ @@ -163,7 +174,7 @@ def extend_renewal_date_for_all_active_subscribers(self, mass_extend_renewal_dat :return: A response that indicates the server successfully received the subscription-renewal-date extension request. :throws APIException: If a response was returned indicating the request could not be processed """ - return self._make_request("/inApps/v1/subscriptions/extend/mass/", "POST", {}, mass_extend_renewal_date_request, MassExtendRenewalDateResponse) + return self._make_request("/inApps/v1/subscriptions/extend/mass", "POST", {}, mass_extend_renewal_date_request, MassExtendRenewalDateResponse) def extend_subscription_renewal_date(self, original_transaction_id: str, extend_renewal_date_request: ExtendRenewalDateRequest) -> ExtendRenewalDateResponse: """ diff --git a/appstoreserverlibrary/models/AccountTenure.py b/appstoreserverlibrary/models/AccountTenure.py index 2be93af..fbe947a 100644 --- a/appstoreserverlibrary/models/AccountTenure.py +++ b/appstoreserverlibrary/models/AccountTenure.py @@ -2,7 +2,9 @@ from enum import IntEnum -class AccountTenure(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class AccountTenure(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ The age of the customer's account. diff --git a/appstoreserverlibrary/models/AppTransaction.py b/appstoreserverlibrary/models/AppTransaction.py index b0cc41b..7093a10 100644 --- a/appstoreserverlibrary/models/AppTransaction.py +++ b/appstoreserverlibrary/models/AppTransaction.py @@ -4,23 +4,30 @@ from attr import define import attr +from .LibraryUtility import AttrsRawValueAware + from .Environment import Environment @define -class AppTransaction: +class AppTransaction(AttrsRawValueAware): """ Information that represents the customer’s purchase of the app, cryptographically signed by the App Store. https://developer.apple.com/documentation/storekit/apptransaction """ - receiptType: Optional[Environment] = attr.ib(default=None) + receiptType: Optional[Environment] = Environment.create_main_attr('rawReceiptType') """ The server environment that signs the app transaction. https://developer.apple.com/documentation/storekit/apptransaction/3963901-environment """ + rawReceiptType: Optional[str] = Environment.create_raw_attr('receiptType') + """ + See receiptType + """ + appAppleId: Optional[int] = attr.ib(default=None) """ The unique identifier the App Store uses to identify the app. diff --git a/appstoreserverlibrary/models/AutoRenewStatus.py b/appstoreserverlibrary/models/AutoRenewStatus.py index 1d69519..c12deff 100644 --- a/appstoreserverlibrary/models/AutoRenewStatus.py +++ b/appstoreserverlibrary/models/AutoRenewStatus.py @@ -2,7 +2,9 @@ from enum import IntEnum -class AutoRenewStatus(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class AutoRenewStatus(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ The renewal status for an auto-renewable subscription. diff --git a/appstoreserverlibrary/models/ConsumptionRequest.py b/appstoreserverlibrary/models/ConsumptionRequest.py index eb6e7ee..c979710 100644 --- a/appstoreserverlibrary/models/ConsumptionRequest.py +++ b/appstoreserverlibrary/models/ConsumptionRequest.py @@ -3,10 +3,11 @@ from attr import define import attr -from .AccountTenure import AccountTenure +from .AccountTenure import AccountTenure from .ConsumptionStatus import ConsumptionStatus from .DeliveryStatus import DeliveryStatus +from .LibraryUtility import AttrsRawValueAware from .LifetimeDollarsPurchased import LifetimeDollarsPurchased from .LifetimeDollarsRefunded import LifetimeDollarsRefunded from .Platform import Platform @@ -14,7 +15,7 @@ from .UserStatus import UserStatus @define -class ConsumptionRequest: +class ConsumptionRequest(AttrsRawValueAware): """ The request body containing consumption information. @@ -28,20 +29,30 @@ class ConsumptionRequest: https://developer.apple.com/documentation/appstoreserverapi/customerconsented """ - consumptionStatus: Optional[ConsumptionStatus] = attr.ib(default=None) + consumptionStatus: Optional[ConsumptionStatus] = ConsumptionStatus.create_main_attr('rawConsumptionStatus') """ A value that indicates the extent to which the customer consumed the in-app purchase. https://developer.apple.com/documentation/appstoreserverapi/consumptionstatus """ - platform: Optional[Platform] = attr.ib(default=None) + rawConsumptionStatus: Optional[int] = ConsumptionStatus.create_raw_attr('consumptionStatus') + """ + See consumptionStatus + """ + + platform: Optional[Platform] = Platform.create_main_attr('rawPlatform') """ A value that indicates the platform on which the customer consumed the in-app purchase. https://developer.apple.com/documentation/appstoreserverapi/platform """ + rawPlatform: Optional[int] = Platform.create_raw_attr('platform') + """ + See platform + """ + sampleContentProvided: Optional[bool] = attr.ib(default=None) """ A Boolean value that indicates whether you provided, prior to its purchase, a free sample or trial of the content, or information about its functionality. @@ -49,13 +60,18 @@ class ConsumptionRequest: https://developer.apple.com/documentation/appstoreserverapi/samplecontentprovided """ - deliveryStatus: Optional[DeliveryStatus] = attr.ib(default=None) + deliveryStatus: Optional[DeliveryStatus] = DeliveryStatus.create_main_attr('rawDeliveryStatus') """ A value that indicates whether the app successfully delivered an in-app purchase that works properly. https://developer.apple.com/documentation/appstoreserverapi/deliverystatus """ + rawDeliveryStatus: Optional[int] = DeliveryStatus.create_raw_attr('deliveryStatus') + """ + See deliveryStatus + """ + appAccountToken: Optional[str] = attr.ib(default=None) """ The UUID that an app optionally generates to map a customer's in-app purchase with its resulting App Store transaction. @@ -63,37 +79,62 @@ class ConsumptionRequest: https://developer.apple.com/documentation/appstoreserverapi/appaccounttoken """ - accountTenure: Optional[AccountTenure] = attr.ib(default=None) + accountTenure: Optional[AccountTenure] = AccountTenure.create_main_attr('rawAccountTenure') """ The age of the customer's account. https://developer.apple.com/documentation/appstoreserverapi/accounttenure """ - playTime: Optional[PlayTime] = attr.ib(default=None) + rawAccountTenure: Optional[int] = AccountTenure.create_raw_attr('accountTenure') + """ + See accountTenure + """ + + playTime: Optional[PlayTime] = PlayTime.create_main_attr('rawPlayTime') """ A value that indicates the amount of time that the customer used the app. https://developer.apple.com/documentation/appstoreserverapi/consumptionrequest """ - lifetimeDollarsRefunded: Optional[LifetimeDollarsRefunded] = attr.ib(default=None) + rawPlayTime: Optional[int] = PlayTime.create_raw_attr('playTime') + """ + See playTime + """ + + lifetimeDollarsRefunded: Optional[LifetimeDollarsRefunded] = LifetimeDollarsRefunded.create_main_attr('rawLifetimeDollarsRefunded') """ A value that indicates the total amount, in USD, of refunds the customer has received, in your app, across all platforms. https://developer.apple.com/documentation/appstoreserverapi/lifetimedollarsrefunded """ - lifetimeDollarsPurchased: Optional[LifetimeDollarsPurchased] = attr.ib(default=None) + rawLifetimeDollarsRefunded: Optional[int] = LifetimeDollarsRefunded.create_raw_attr('lifetimeDollarsRefunded') + """ + See lifetimeDollarsRefunded + """ + + lifetimeDollarsPurchased: Optional[LifetimeDollarsPurchased] = LifetimeDollarsPurchased.create_main_attr('rawLifetimeDollarsPurchased') """ A value that indicates the total amount, in USD, of in-app purchases the customer has made in your app, across all platforms. https://developer.apple.com/documentation/appstoreserverapi/lifetimedollarspurchased """ - userStatus: Optional[UserStatus] = attr.ib(default=None) + rawLifetimeDollarsPurchased: Optional[int] = LifetimeDollarsPurchased.create_raw_attr('lifetimeDollarsPurchased') + """ + See lifetimeDollarsPurchased + """ + + userStatus: Optional[UserStatus] = UserStatus.create_main_attr('rawUserStatus') """ The status of the customer's account. https://developer.apple.com/documentation/appstoreserverapi/userstatus + """ + + rawUserStatus: Optional[int] = UserStatus.create_raw_attr('userStatus') + """ + See userStatus """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/ConsumptionStatus.py b/appstoreserverlibrary/models/ConsumptionStatus.py index a872e7e..0a738ce 100644 --- a/appstoreserverlibrary/models/ConsumptionStatus.py +++ b/appstoreserverlibrary/models/ConsumptionStatus.py @@ -2,7 +2,9 @@ from enum import IntEnum -class ConsumptionStatus(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class ConsumptionStatus(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ A value that indicates the extent to which the customer consumed the in-app purchase. diff --git a/appstoreserverlibrary/models/Data.py b/appstoreserverlibrary/models/Data.py index d28dab9..28856d2 100644 --- a/appstoreserverlibrary/models/Data.py +++ b/appstoreserverlibrary/models/Data.py @@ -6,21 +6,25 @@ from .Environment import Environment from .Status import Status +from .LibraryUtility import AttrsRawValueAware @define -class Data: +class Data(AttrsRawValueAware): """ The app metadata and the signed renewal and transaction information. https://developer.apple.com/documentation/appstoreservernotifications/data """ - - environment: Optional[Environment] = attr.ib(default=None) + environment: Optional[Environment] = Environment.create_main_attr('rawEnvironment') """ The server environment that the notification applies to, either sandbox or production. https://developer.apple.com/documentation/appstoreservernotifications/environment """ + rawEnvironment: Optional[str] = Environment.create_raw_attr('environment') + """ + See environment + """ appAppleId: Optional[int] = attr.ib(default=None) """ @@ -57,9 +61,14 @@ class Data: https://developer.apple.com/documentation/appstoreserverapi/jwsrenewalinfo """ - status: Optional[Status] = attr.ib(default=None) + status: Optional[Status] = Status.create_main_attr('rawStatus') """ The status of an auto-renewable subscription as of the signedDate in the responseBodyV2DecodedPayload. https://developer.apple.com/documentation/appstoreservernotifications/status + """ + + rawStatus: Optional[int] = Status.create_raw_attr('status') + """ + See status """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/DeliveryStatus.py b/appstoreserverlibrary/models/DeliveryStatus.py index 43b23fe..eee9909 100644 --- a/appstoreserverlibrary/models/DeliveryStatus.py +++ b/appstoreserverlibrary/models/DeliveryStatus.py @@ -2,7 +2,9 @@ from enum import IntEnum -class DeliveryStatus(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class DeliveryStatus(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ A value that indicates whether the app successfully delivered an in-app purchase that works properly. diff --git a/appstoreserverlibrary/models/Environment.py b/appstoreserverlibrary/models/Environment.py index 3f9e580..e5625a1 100644 --- a/appstoreserverlibrary/models/Environment.py +++ b/appstoreserverlibrary/models/Environment.py @@ -2,7 +2,9 @@ from enum import Enum -class Environment(str, Enum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class Environment(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ The server environment, either sandbox or production. diff --git a/appstoreserverlibrary/models/ExpirationIntent.py b/appstoreserverlibrary/models/ExpirationIntent.py index cfdf5ee..120082f 100644 --- a/appstoreserverlibrary/models/ExpirationIntent.py +++ b/appstoreserverlibrary/models/ExpirationIntent.py @@ -2,7 +2,9 @@ from enum import IntEnum -class ExpirationIntent(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class ExpirationIntent(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ The reason an auto-renewable subscription expired. diff --git a/appstoreserverlibrary/models/ExtendReasonCode.py b/appstoreserverlibrary/models/ExtendReasonCode.py index d5dc691..4143f9e 100644 --- a/appstoreserverlibrary/models/ExtendReasonCode.py +++ b/appstoreserverlibrary/models/ExtendReasonCode.py @@ -2,7 +2,9 @@ from enum import IntEnum -class ExtendReasonCode(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class ExtendReasonCode(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ The code that represents the reason for the subscription-renewal-date extension. diff --git a/appstoreserverlibrary/models/FirstSendAttemptResult.py b/appstoreserverlibrary/models/FirstSendAttemptResult.py index b8b347c..6c2e1f5 100644 --- a/appstoreserverlibrary/models/FirstSendAttemptResult.py +++ b/appstoreserverlibrary/models/FirstSendAttemptResult.py @@ -2,8 +2,10 @@ from enum import Enum, unique +from .LibraryUtility import AppStoreServerLibraryEnumMeta + @unique -class FirstSendAttemptResult(str, Enum): +class FirstSendAttemptResult(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ An error or result that the App Store server receives when attempting to send an App Store server notification to your server. diff --git a/appstoreserverlibrary/models/HistoryResponse.py b/appstoreserverlibrary/models/HistoryResponse.py index cc7a0be..429356c 100644 --- a/appstoreserverlibrary/models/HistoryResponse.py +++ b/appstoreserverlibrary/models/HistoryResponse.py @@ -5,9 +5,10 @@ import attr from .Environment import Environment +from .LibraryUtility import AttrsRawValueAware @define -class HistoryResponse: +class HistoryResponse(AttrsRawValueAware): """ A response that contains the customer's transaction history for an app. @@ -42,13 +43,18 @@ class HistoryResponse: https://developer.apple.com/documentation/appstoreservernotifications/appappleid """ - environment: Optional[Environment] = attr.ib(default=None) + environment: Optional[Environment] = Environment.create_main_attr('rawEnvironment') """ The server environment in which you're making the request, whether sandbox or production. https://developer.apple.com/documentation/appstoreserverapi/environment """ + rawEnvironment: Optional[str] = Environment.create_raw_attr('environment') + """ + See environment + """ + signedTransactions: Optional[List[str]] = attr.ib(default=None) """ An array of in-app purchase transactions for the customer, signed by Apple, in JSON Web Signature format. diff --git a/appstoreserverlibrary/models/InAppOwnershipType.py b/appstoreserverlibrary/models/InAppOwnershipType.py index 03e12d5..b7d7cd3 100644 --- a/appstoreserverlibrary/models/InAppOwnershipType.py +++ b/appstoreserverlibrary/models/InAppOwnershipType.py @@ -2,7 +2,9 @@ from enum import Enum -class InAppOwnershipType(str, Enum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class InAppOwnershipType(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ The relationship of the user with the family-shared purchase to which they have access. diff --git a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py index 50da25b..023892e 100644 --- a/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py @@ -7,24 +7,30 @@ from .Environment import Environment from .ExpirationIntent import ExpirationIntent +from .LibraryUtility import AttrsRawValueAware from .OfferType import OfferType from .PriceIncreaseStatus import PriceIncreaseStatus @define -class JWSRenewalInfoDecodedPayload: +class JWSRenewalInfoDecodedPayload(AttrsRawValueAware): """ A decoded payload containing subscription renewal information for an auto-renewable subscription. https://developer.apple.com/documentation/appstoreserverapi/jwsrenewalinfodecodedpayload """ - expirationIntent: Optional[ExpirationIntent] = attr.ib(default=None) + expirationIntent: Optional[ExpirationIntent] = ExpirationIntent.create_main_attr('rawExpirationIntent') """ The reason the subscription expired. https://developer.apple.com/documentation/appstoreserverapi/expirationintent """ + rawExpirationIntent: Optional[int] = ExpirationIntent.create_raw_attr('expirationIntent') + """ + See expirationIntent + """ + originalTransactionId: Optional[str] = attr.ib(default=None) """ The original transaction identifier of a purchase. @@ -46,13 +52,18 @@ class JWSRenewalInfoDecodedPayload: https://developer.apple.com/documentation/appstoreserverapi/productid """ - autoRenewStatus: Optional[AutoRenewStatus] = attr.ib(default=None) + autoRenewStatus: Optional[AutoRenewStatus] = AutoRenewStatus.create_main_attr('rawAutoRenewStatus') """ The renewal status of the auto-renewable subscription. https://developer.apple.com/documentation/appstoreserverapi/autorenewstatus """ + rawAutoRenewStatus: Optional[int] = AutoRenewStatus.create_raw_attr('autoRenewStatus') + """ + See autoRenewStatus + """ + isInBillingRetryPeriod: Optional[bool] = attr.ib(default=None) """ A Boolean value that indicates whether the App Store is attempting to automatically renew an expired subscription. @@ -60,13 +71,18 @@ class JWSRenewalInfoDecodedPayload: https://developer.apple.com/documentation/appstoreserverapi/isinbillingretryperiod """ - priceIncreaseStatus: Optional[PriceIncreaseStatus] = attr.ib(default=None) + priceIncreaseStatus: Optional[PriceIncreaseStatus] = PriceIncreaseStatus.create_main_attr('rawPriceIncreaseStatus') """ The status that indicates whether the auto-renewable subscription is subject to a price increase. https://developer.apple.com/documentation/appstoreserverapi/priceincreasestatus """ + rawPriceIncreaseStatus: Optional[int] = PriceIncreaseStatus.create_raw_attr('priceIncreaseStatus') + """ + See priceIncreaseStatus + """ + gracePeriodExpiresDate: Optional[int] = attr.ib(default=None) """ The time when the billing grace period for subscription renewals expires. @@ -74,13 +90,18 @@ class JWSRenewalInfoDecodedPayload: https://developer.apple.com/documentation/appstoreserverapi/graceperiodexpiresdate """ - offerType: Optional[OfferType] = attr.ib(default=None) + offerType: Optional[OfferType] = OfferType.create_main_attr('rawOfferType') """ The type of the subscription offer. https://developer.apple.com/documentation/appstoreserverapi/offertype """ + rawOfferType: Optional[int] = OfferType.create_raw_attr('offerType') + """ + See offerType + """ + offerIdentifier: Optional[str] = attr.ib(default=None) """ The identifier that contains the promo code or the promotional offer identifier. @@ -95,13 +116,18 @@ class JWSRenewalInfoDecodedPayload: https://developer.apple.com/documentation/appstoreserverapi/signeddate """ - environment: Optional[Environment] = attr.ib(default=None) + environment: Optional[Environment] = Environment.create_main_attr('rawEnvironment') """ The server environment, either sandbox or production. https://developer.apple.com/documentation/appstoreserverapi/environment """ + rawEnvironment: Optional[str] = Environment.create_raw_attr('environment') + """ + See environment + """ + recentSubscriptionStartDate: Optional[int] = attr.ib(default=None) """ The earliest start date of a subscription in a series of auto-renewable subscription purchases that ignores all lapses of paid service shorter than 60 days. diff --git a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py index baaa22f..1e5742f 100644 --- a/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py +++ b/appstoreserverlibrary/models/JWSTransactionDecodedPayload.py @@ -5,6 +5,7 @@ import attr from .Environment import Environment from .InAppOwnershipType import InAppOwnershipType +from .LibraryUtility import AttrsRawValueAware from .OfferType import OfferType from .RevocationReason import RevocationReason from .TransactionReason import TransactionReason @@ -12,7 +13,7 @@ from .Type import Type @define -class JWSTransactionDecodedPayload: +class JWSTransactionDecodedPayload(AttrsRawValueAware): """ A decoded payload containing transaction information. @@ -89,13 +90,18 @@ class JWSTransactionDecodedPayload: https://developer.apple.com/documentation/appstoreserverapi/quantity """ - type: Optional[Type] = attr.ib(default=None) + type: Optional[Type] = Type.create_main_attr('rawType') """ The type of the in-app purchase. https://developer.apple.com/documentation/appstoreserverapi/type """ + rawType: Optional[str] = Type.create_raw_attr('type') + """ + See type + """ + appAccountToken: Optional[str] = attr.ib(default=None) """ The UUID that an app optionally generates to map a customer's in-app purchase with its resulting App Store transaction. @@ -103,13 +109,18 @@ class JWSTransactionDecodedPayload: https://developer.apple.com/documentation/appstoreserverapi/appaccounttoken """ - inAppOwnershipType: Optional[InAppOwnershipType] = attr.ib(default=None) + inAppOwnershipType: Optional[InAppOwnershipType] = InAppOwnershipType.create_main_attr('rawInAppOwnershipType') """ A string that describes whether the transaction was purchased by the user, or is available to them through Family Sharing. https://developer.apple.com/documentation/appstoreserverapi/inappownershiptype """ + rawInAppOwnershipType: Optional[str] = InAppOwnershipType.create_raw_attr('inAppOwnershipType') + """ + See inAppOwnershipType + """ + signedDate: Optional[int] = attr.ib(default=None) """ The UNIX time, in milliseconds, that the App Store signed the JSON Web Signature data. @@ -117,13 +128,18 @@ class JWSTransactionDecodedPayload: https://developer.apple.com/documentation/appstoreserverapi/signeddate """ - revocationReason: Optional[RevocationReason] = attr.ib(default=None) + revocationReason: Optional[RevocationReason] = RevocationReason.create_main_attr('rawRevocationReason') """ The reason that the App Store refunded the transaction or revoked it from family sharing. https://developer.apple.com/documentation/appstoreserverapi/revocationreason """ + rawRevocationReason: Optional[int] = RevocationReason.create_raw_attr('revocationReason') + """ + See revocationReason + """ + revocationDate: Optional[int] = attr.ib(default=None) """ The UNIX time, in milliseconds, that Apple Support refunded a transaction. @@ -138,13 +154,18 @@ class JWSTransactionDecodedPayload: https://developer.apple.com/documentation/appstoreserverapi/isupgraded """ - offerType: Optional[OfferType] = attr.ib(default=None) + offerType: Optional[OfferType] = RevocationReason.create_main_attr('rawOfferType') """ A value that represents the promotional offer type. https://developer.apple.com/documentation/appstoreserverapi/offertype """ + rawOfferType: Optional[int] = OfferType.create_raw_attr('offerType') + """ + See offerType + """ + offerIdentifier: Optional[str] = attr.ib(default=None) """ The identifier that contains the promo code or the promotional offer identifier. @@ -152,13 +173,18 @@ class JWSTransactionDecodedPayload: https://developer.apple.com/documentation/appstoreserverapi/offeridentifier """ - environment: Optional[Environment] = attr.ib(default=None) + environment: Optional[Environment] = Environment.create_main_attr('rawEnvironment') """ The server environment, either sandbox or production. https://developer.apple.com/documentation/appstoreserverapi/environment """ + rawEnvironment: Optional[str] = Environment.create_raw_attr('environment') + """ + See environment + """ + storefront: Optional[str] = attr.ib(default=None) """ The three-letter code that represents the country or region associated with the App Store storefront for the purchase. @@ -173,9 +199,14 @@ class JWSTransactionDecodedPayload: https://developer.apple.com/documentation/appstoreserverapi/storefrontid """ - transactionReason: Optional[TransactionReason] = attr.ib(default=None) + transactionReason: Optional[TransactionReason] = TransactionReason.create_main_attr('rawTransactionReason') """ The reason for the purchase transaction, which indicates whether it's a customer's purchase or a renewal for an auto-renewable subscription that the system initates. https://developer.apple.com/documentation/appstoreserverapi/transactionreason + """ + + rawTransactionReason: Optional[str] = TransactionReason.create_raw_attr('transactionReason') + """ + See transactionReason """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/LastTransactionsItem.py b/appstoreserverlibrary/models/LastTransactionsItem.py index 694abfd..63b9027 100644 --- a/appstoreserverlibrary/models/LastTransactionsItem.py +++ b/appstoreserverlibrary/models/LastTransactionsItem.py @@ -4,23 +4,29 @@ from attr import define import attr -from appstoreserverlibrary.models.Status import Status +from .LibraryUtility import AttrsRawValueAware +from .Status import Status @define -class LastTransactionsItem: +class LastTransactionsItem(AttrsRawValueAware): """ The most recent App Store-signed transaction information and App Store-signed renewal information for an auto-renewable subscription. https://developer.apple.com/documentation/appstoreserverapi/lasttransactionsitem """ - status: Optional[Status] = attr.ib(default=None) + status: Optional[Status] = Status.create_main_attr('rawStatus') """ The status of the auto-renewable subscription. https://developer.apple.com/documentation/appstoreserverapi/status """ + rawStatus: Optional[int] = Status.create_raw_attr('status') + """ + See status + """ + originalTransactionId: Optional[str] = attr.ib(default=None) """ The original transaction identifier of a purchase. diff --git a/appstoreserverlibrary/models/LibraryUtility.py b/appstoreserverlibrary/models/LibraryUtility.py new file mode 100644 index 0000000..31add6c --- /dev/null +++ b/appstoreserverlibrary/models/LibraryUtility.py @@ -0,0 +1,69 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from enum import EnumMeta +from typing import Any, List, Type, TypeVar + +from attr import Attribute, has, ib, fields +from cattr import override +from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override +import cattrs + +T = TypeVar('T') + +metadata_key = 'correspondingFieldName' +metadata_type_key = 'typeOfField' + +class AppStoreServerLibraryEnumMeta(EnumMeta): + def __contains__(c, val): + try: + c(val) + except ValueError: + return False + return True + + def create_main_attr(c, raw_field_name: str) -> Any: + def value_set(self, _: Attribute, value: c): + newValue = value.value if value is not None else None + if newValue != getattr(self, raw_field_name): + object.__setattr__(self, raw_field_name, newValue) + return value + return ib(default=None, on_setattr=value_set, metadata={metadata_key: raw_field_name, metadata_type_key: 'main'}) + + def create_raw_attr(c, field_name: str) -> Any: + def value_set(self, _: Attribute, value: str): + newValue = c(value) if value in c else None + if newValue != getattr(self, field_name): + object.__setattr__(self, field_name, newValue) + return value + return ib(default=None, kw_only=True, on_setattr=value_set, metadata={metadata_key: field_name, metadata_type_key: 'raw'}) + +class AttrsRawValueAware: + def __attrs_post_init__(self): + attr_fields: List[Attribute] = fields(type(self)) + for attribute in attr_fields: + if metadata_type_key not in attribute.metadata or attribute.metadata[metadata_type_key] != 'raw': + continue + field: str = attribute.metadata.get(metadata_key) + rawField = 'raw' + field[0].upper() + field[1:] + rawValue = getattr(self, rawField) + value = getattr(self, field) + if rawValue is not None: + setattr(self, rawField, rawValue) + elif value is not None: + setattr(self, field, value) + + +def _get_cattrs_converter(destination_class: Type[T]) -> cattrs.Converter: + c = cattrs.Converter() + attributes: List[Attribute] = fields(destination_class) + cattrs_overrides = {} + for attribute in attributes: + if metadata_type_key in attribute.metadata: + matching_name: str = attribute.metadata[metadata_key] + if attribute.metadata[metadata_type_key] == 'raw': + cattrs_overrides[matching_name] = override(omit=True) + raw_field = 'raw' + matching_name[0].upper() + matching_name[1:] + cattrs_overrides[raw_field] = override(rename=matching_name) + c.register_structure_hook_factory(has, lambda cl: make_dict_structure_fn(cl, c, **cattrs_overrides)) + c.register_unstructure_hook_factory(has, lambda cl: make_dict_unstructure_fn(cl, c, **cattrs_overrides)) + return c \ No newline at end of file diff --git a/appstoreserverlibrary/models/LifetimeDollarsPurchased.py b/appstoreserverlibrary/models/LifetimeDollarsPurchased.py index 85e3b6f..3327f07 100644 --- a/appstoreserverlibrary/models/LifetimeDollarsPurchased.py +++ b/appstoreserverlibrary/models/LifetimeDollarsPurchased.py @@ -2,7 +2,9 @@ from enum import IntEnum -class LifetimeDollarsPurchased(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class LifetimeDollarsPurchased(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ A value that indicates the total amount, in USD, of in-app purchases the customer has made in your app, across all platforms. diff --git a/appstoreserverlibrary/models/LifetimeDollarsRefunded.py b/appstoreserverlibrary/models/LifetimeDollarsRefunded.py index cf9034e..6e340a8 100644 --- a/appstoreserverlibrary/models/LifetimeDollarsRefunded.py +++ b/appstoreserverlibrary/models/LifetimeDollarsRefunded.py @@ -2,7 +2,9 @@ from enum import IntEnum -class LifetimeDollarsRefunded(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class LifetimeDollarsRefunded(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ A value that indicates the dollar amount of refunds the customer has received in your app, since purchasing the app, across all platforms. diff --git a/appstoreserverlibrary/models/NotificationTypeV2.py b/appstoreserverlibrary/models/NotificationTypeV2.py index e09d884..e667a05 100644 --- a/appstoreserverlibrary/models/NotificationTypeV2.py +++ b/appstoreserverlibrary/models/NotificationTypeV2.py @@ -2,7 +2,9 @@ from enum import Enum -class NotificationTypeV2(str, Enum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class NotificationTypeV2(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ A notification type value that App Store Server Notifications V2 uses. diff --git a/appstoreserverlibrary/models/OfferType.py b/appstoreserverlibrary/models/OfferType.py index 0ee3131..f23799c 100644 --- a/appstoreserverlibrary/models/OfferType.py +++ b/appstoreserverlibrary/models/OfferType.py @@ -2,7 +2,9 @@ from enum import IntEnum -class OfferType(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class OfferType(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ The type of subscription offer. diff --git a/appstoreserverlibrary/models/OrderLookupResponse.py b/appstoreserverlibrary/models/OrderLookupResponse.py index 8b7916b..faaf42d 100644 --- a/appstoreserverlibrary/models/OrderLookupResponse.py +++ b/appstoreserverlibrary/models/OrderLookupResponse.py @@ -4,23 +4,29 @@ from typing import List, Optional import attr +from .LibraryUtility import AttrsRawValueAware from .OrderLookupStatus import OrderLookupStatus @define -class OrderLookupResponse: +class OrderLookupResponse(AttrsRawValueAware): """ A response that includes the order lookup status and an array of signed transactions for the in-app purchases in the order. https://developer.apple.com/documentation/appstoreserverapi/orderlookupresponse """ - status: Optional[OrderLookupStatus] = attr.ib(default=None) + status: Optional[OrderLookupStatus] = OrderLookupStatus.create_main_attr('rawStatus') """ The status that indicates whether the order ID is valid. https://developer.apple.com/documentation/appstoreserverapi/orderlookupstatus """ + rawStatus: Optional[int] = OrderLookupStatus.create_raw_attr('status') + """ + See status + """ + signedTransactions: Optional[List[str]] = attr.ib(default=None) """ An array of in-app purchase transactions that are part of order, signed by Apple, in JSON Web Signature format. diff --git a/appstoreserverlibrary/models/OrderLookupStatus.py b/appstoreserverlibrary/models/OrderLookupStatus.py index f1a7341..ee01557 100644 --- a/appstoreserverlibrary/models/OrderLookupStatus.py +++ b/appstoreserverlibrary/models/OrderLookupStatus.py @@ -2,7 +2,9 @@ from enum import IntEnum -class OrderLookupStatus(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class OrderLookupStatus(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ A value that indicates whether the order ID in the request is valid for your app. diff --git a/appstoreserverlibrary/models/Platform.py b/appstoreserverlibrary/models/Platform.py index bc0a552..ff380bb 100644 --- a/appstoreserverlibrary/models/Platform.py +++ b/appstoreserverlibrary/models/Platform.py @@ -2,7 +2,9 @@ from enum import IntEnum -class Platform(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class Platform(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ The platform on which the customer consumed the in-app purchase. diff --git a/appstoreserverlibrary/models/PlayTime.py b/appstoreserverlibrary/models/PlayTime.py index e02cc01..8bc3a09 100644 --- a/appstoreserverlibrary/models/PlayTime.py +++ b/appstoreserverlibrary/models/PlayTime.py @@ -2,7 +2,9 @@ from enum import IntEnum -class PlayTime(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class PlayTime(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ A value that indicates the amount of time that the customer used the app. diff --git a/appstoreserverlibrary/models/PriceIncreaseStatus.py b/appstoreserverlibrary/models/PriceIncreaseStatus.py index 5cca054..f1ecfb5 100644 --- a/appstoreserverlibrary/models/PriceIncreaseStatus.py +++ b/appstoreserverlibrary/models/PriceIncreaseStatus.py @@ -2,7 +2,9 @@ from enum import IntEnum -class PriceIncreaseStatus(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class PriceIncreaseStatus(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ The status that indicates whether an auto-renewable subscription is subject to a price increase. diff --git a/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py b/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py index 9219238..f9400c7 100644 --- a/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py +++ b/appstoreserverlibrary/models/ResponseBodyV2DecodedPayload.py @@ -5,32 +5,43 @@ import attr from .Data import Data +from .LibraryUtility import AttrsRawValueAware from .NotificationTypeV2 import NotificationTypeV2 from .Subtype import Subtype from .Summary import Summary @define -class ResponseBodyV2DecodedPayload: +class ResponseBodyV2DecodedPayload(AttrsRawValueAware): """ A decoded payload containing the version 2 notification data. https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv2decodedpayload """ - notificationType: Optional[NotificationTypeV2] = attr.ib(default=None) + notificationType: Optional[NotificationTypeV2] = NotificationTypeV2.create_main_attr('rawNotificationType') """ The in-app purchase event for which the App Store sends this version 2 notification. https://developer.apple.com/documentation/appstoreservernotifications/notificationtype """ - subtype: Optional[Subtype] = attr.ib(default=None) + rawNotificationType: Optional[str] = NotificationTypeV2.create_raw_attr('notificationType') + """ + See notificationType + """ + + subtype: Optional[Subtype] = Subtype.create_main_attr('rawSubtype') """ Additional information that identifies the notification event. The subtype field is present only for specific version 2 notifications. https://developer.apple.com/documentation/appstoreservernotifications/subtype """ + + rawSubtype: Optional[str] = Subtype.create_raw_attr('subtype') + """ + See subtype + """ notificationUUID: Optional[str] = attr.ib(default=None) """ diff --git a/appstoreserverlibrary/models/RevocationReason.py b/appstoreserverlibrary/models/RevocationReason.py index 51100c2..5b1dd8e 100644 --- a/appstoreserverlibrary/models/RevocationReason.py +++ b/appstoreserverlibrary/models/RevocationReason.py @@ -2,7 +2,9 @@ from enum import IntEnum -class RevocationReason(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class RevocationReason(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ The reason for a refunded transaction. diff --git a/appstoreserverlibrary/models/SendAttemptItem.py b/appstoreserverlibrary/models/SendAttemptItem.py index 7aeaa30..c7927e9 100644 --- a/appstoreserverlibrary/models/SendAttemptItem.py +++ b/appstoreserverlibrary/models/SendAttemptItem.py @@ -4,10 +4,11 @@ from attr import define import attr +from .LibraryUtility import AttrsRawValueAware from .SendAttemptResult import SendAttemptResult @define -class SendAttemptItem: +class SendAttemptItem(AttrsRawValueAware): """ The success or error information and the date the App Store server records when it attempts to send a server notification to your server. @@ -21,9 +22,14 @@ class SendAttemptItem: https://developer.apple.com/documentation/appstoreserverapi/attemptdate """ - sendAttemptResult: Optional[SendAttemptResult] = attr.ib(default=None) + sendAttemptResult: Optional[SendAttemptResult] = SendAttemptResult.create_main_attr('rawSendAttemptResult') """ The success or error information the App Store server records when it attempts to send an App Store server notification to your server. https://developer.apple.com/documentation/appstoreserverapi/sendattemptresult + """ + + rawSendAttemptResult: Optional[str] = SendAttemptResult.create_raw_attr('sendAttemptResult') + """ + See sendAttemptResult """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/SendAttemptResult.py b/appstoreserverlibrary/models/SendAttemptResult.py index b15b07d..5dbb7a2 100644 --- a/appstoreserverlibrary/models/SendAttemptResult.py +++ b/appstoreserverlibrary/models/SendAttemptResult.py @@ -2,7 +2,9 @@ from enum import Enum -class SendAttemptResult(str, Enum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class SendAttemptResult(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ The success or error information the App Store server records when it attempts to send an App Store server notification to your server. diff --git a/appstoreserverlibrary/models/Status.py b/appstoreserverlibrary/models/Status.py index 63236c8..56abee2 100644 --- a/appstoreserverlibrary/models/Status.py +++ b/appstoreserverlibrary/models/Status.py @@ -2,7 +2,9 @@ from enum import IntEnum -class Status(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class Status(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ The status of an auto-renewable subscription. diff --git a/appstoreserverlibrary/models/StatusResponse.py b/appstoreserverlibrary/models/StatusResponse.py index 8383426..8b2fb0f 100644 --- a/appstoreserverlibrary/models/StatusResponse.py +++ b/appstoreserverlibrary/models/StatusResponse.py @@ -3,23 +3,30 @@ from attr import define import attr + from .Environment import Environment +from .LibraryUtility import AttrsRawValueAware from .SubscriptionGroupIdentifierItem import SubscriptionGroupIdentifierItem @define -class StatusResponse: +class StatusResponse(AttrsRawValueAware): """ A response that contains status information for all of a customer's auto-renewable subscriptions in your app. https://developer.apple.com/documentation/appstoreserverapi/statusresponse """ - environment: Optional[Environment] = attr.ib(default=None) + environment: Optional[Environment] = Environment.create_main_attr('rawEnvironment') """ The server environment, sandbox or production, in which the App Store generated the response. https://developer.apple.com/documentation/appstoreserverapi/environment """ + + rawEnvironment: Optional[str] = Environment.create_raw_attr('environment') + """ + See environment + """ bundleId: Optional[str] = attr.ib(default=None) """ diff --git a/appstoreserverlibrary/models/Subtype.py b/appstoreserverlibrary/models/Subtype.py index a6ebbd0..c816764 100644 --- a/appstoreserverlibrary/models/Subtype.py +++ b/appstoreserverlibrary/models/Subtype.py @@ -2,7 +2,9 @@ from enum import Enum -class Subtype(str, Enum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class Subtype(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ A notification subtype value that App Store Server Notifications 2 uses. diff --git a/appstoreserverlibrary/models/Summary.py b/appstoreserverlibrary/models/Summary.py index bc7fedd..708d828 100644 --- a/appstoreserverlibrary/models/Summary.py +++ b/appstoreserverlibrary/models/Summary.py @@ -3,22 +3,29 @@ from attr import define from typing import List, Optional import attr + from .Environment import Environment +from .LibraryUtility import AttrsRawValueAware @define -class Summary: +class Summary(AttrsRawValueAware): """ The payload data for a subscription-renewal-date extension notification. https://developer.apple.com/documentation/appstoreservernotifications/summary """ - environment: Optional[Environment] = attr.ib(default=None) + environment: Optional[Environment] = Environment.create_main_attr('rawEnvironment') """ The server environment that the notification applies to, either sandbox or production. https://developer.apple.com/documentation/appstoreservernotifications/environment """ + + rawEnvironment: Optional[str] = Environment.create_raw_attr('environment') + """ + See environment + """ appAppleId: Optional[int] = attr.ib(default=None) """ diff --git a/appstoreserverlibrary/models/TransactionReason.py b/appstoreserverlibrary/models/TransactionReason.py index ce34332..478875a 100644 --- a/appstoreserverlibrary/models/TransactionReason.py +++ b/appstoreserverlibrary/models/TransactionReason.py @@ -1,8 +1,9 @@ # Copyright (c) 2023 Apple Inc. Licensed under MIT License. from enum import Enum +from .LibraryUtility import AppStoreServerLibraryEnumMeta -class TransactionReason(str, Enum): +class TransactionReason(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ The cause of a purchase transaction, which indicates whether it’s a customer’s purchase or a renewal for an auto-renewable subscription that the system initiates. diff --git a/appstoreserverlibrary/models/Type.py b/appstoreserverlibrary/models/Type.py index 6be0b34..d91e08b 100644 --- a/appstoreserverlibrary/models/Type.py +++ b/appstoreserverlibrary/models/Type.py @@ -1,8 +1,9 @@ # Copyright (c) 2023 Apple Inc. Licensed under MIT License. from enum import Enum +from .LibraryUtility import AppStoreServerLibraryEnumMeta -class Type(str, Enum): +class Type(str, Enum, metaclass=AppStoreServerLibraryEnumMeta): """ The type of in-app purchase products you can offer in your app. diff --git a/appstoreserverlibrary/models/UserStatus.py b/appstoreserverlibrary/models/UserStatus.py index 351b566..2ac617a 100644 --- a/appstoreserverlibrary/models/UserStatus.py +++ b/appstoreserverlibrary/models/UserStatus.py @@ -2,7 +2,9 @@ from enum import IntEnum -class UserStatus(IntEnum): +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class UserStatus(IntEnum, metaclass=AppStoreServerLibraryEnumMeta): """ The status of a customer's account within your app. diff --git a/appstoreserverlibrary/signed_data_verifier.py b/appstoreserverlibrary/signed_data_verifier.py index 858ebc5..cf22e3c 100644 --- a/appstoreserverlibrary/signed_data_verifier.py +++ b/appstoreserverlibrary/signed_data_verifier.py @@ -6,8 +6,6 @@ import time import datetime -import cattrs - import asn1 import jwt import requests @@ -19,6 +17,7 @@ from OpenSSL import crypto from appstoreserverlibrary.models.AppTransaction import AppTransaction +from appstoreserverlibrary.models.LibraryUtility import _get_cattrs_converter from .models.Environment import Environment from .models.ResponseBodyV2DecodedPayload import ResponseBodyV2DecodedPayload @@ -35,7 +34,7 @@ def __init__( enable_online_checks: bool, environment: Environment, bundle_id: str, - app_apple_id: str = None, + app_apple_id: int = None, ): self._chain_verifier = _ChainVerifier(root_certificates) self._environment = environment @@ -51,7 +50,11 @@ def verify_and_decode_renewal_info(self, signed_renewal_info: str) -> JWSRenewal :return: The decoded renewal info after verification :throws VerificationException: Thrown if the data could not be verified """ - return cattrs.structure(self._decode_signed_object(signed_renewal_info), JWSRenewalInfoDecodedPayload) + + decoded_renewal_info = _get_cattrs_converter(JWSRenewalInfoDecodedPayload).structure(self._decode_signed_object(signed_renewal_info), JWSRenewalInfoDecodedPayload) + if decoded_renewal_info.environment != self._environment: + raise VerificationException(VerificationStatus.INVALID_ENVIRONMENT) + return decoded_renewal_info def verify_and_decode_signed_transaction(self, signed_transaction: str) -> JWSTransactionDecodedPayload: """ @@ -61,7 +64,7 @@ def verify_and_decode_signed_transaction(self, signed_transaction: str) -> JWSTr :return: The decoded transaction info after verification :throws VerificationException: Thrown if the data could not be verified """ - decoded_transaction_info = cattrs.structure(self._decode_signed_object(signed_transaction), JWSTransactionDecodedPayload) + decoded_transaction_info = _get_cattrs_converter(JWSTransactionDecodedPayload).structure(self._decode_signed_object(signed_transaction), JWSTransactionDecodedPayload) if decoded_transaction_info.bundleId != self._bundle_id: raise VerificationException(VerificationStatus.INVALID_APP_IDENTIFIER) if decoded_transaction_info.environment != self._environment: @@ -77,7 +80,7 @@ def verify_and_decode_notification(self, signed_payload: str) -> ResponseBodyV2D :throws VerificationException: Thrown if the data could not be verified """ decoded_dict = self._decode_signed_object(signed_payload) - decoded_signed_notification = cattrs.structure(decoded_dict, ResponseBodyV2DecodedPayload) + decoded_signed_notification = _get_cattrs_converter(ResponseBodyV2DecodedPayload).structure(decoded_dict, ResponseBodyV2DecodedPayload) bundle_id = None app_apple_id = None environment = None @@ -104,7 +107,7 @@ def verify_and_decode_app_transaction(self, signed_app_transaction: str) -> AppT :throws VerificationException: Thrown if the data could not be verified """ decoded_dict = self._decode_signed_object(signed_app_transaction) - decoded_app_transaction = cattrs.structure(decoded_dict, AppTransaction) + decoded_app_transaction = _get_cattrs_converter(AppTransaction).structure(decoded_dict, AppTransaction) environment = decoded_app_transaction.receiptType if decoded_app_transaction.bundleId != self._bundle_id or (self._environment == Environment.PRODUCTION and decoded_app_transaction.appAppleId != self._app_apple_id): raise VerificationException(VerificationStatus.INVALID_APP_IDENTIFIER) diff --git a/tests/test_api_client.py b/tests/test_api_client.py new file mode 100644 index 0000000..876b6ec --- /dev/null +++ b/tests/test_api_client.py @@ -0,0 +1,409 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from typing import Any, Dict, List, Union +import unittest + +from requests import Response +from appstoreserverlibrary.api_client import APIError, APIException, AppStoreServerAPIClient +from appstoreserverlibrary.models.AccountTenure import AccountTenure +from appstoreserverlibrary.models.AutoRenewStatus import AutoRenewStatus +from appstoreserverlibrary.models.ConsumptionRequest import ConsumptionRequest +from appstoreserverlibrary.models.ConsumptionStatus import ConsumptionStatus +from appstoreserverlibrary.models.DeliveryStatus import DeliveryStatus +from appstoreserverlibrary.models.Environment import Environment +from appstoreserverlibrary.models.ExpirationIntent import ExpirationIntent +from appstoreserverlibrary.models.ExtendReasonCode import ExtendReasonCode +from appstoreserverlibrary.models.ExtendRenewalDateRequest import ExtendRenewalDateRequest +from appstoreserverlibrary.models.InAppOwnershipType import InAppOwnershipType +from appstoreserverlibrary.models.LastTransactionsItem import LastTransactionsItem +from appstoreserverlibrary.models.LifetimeDollarsPurchased import LifetimeDollarsPurchased +from appstoreserverlibrary.models.LifetimeDollarsRefunded import LifetimeDollarsRefunded +from appstoreserverlibrary.models.MassExtendRenewalDateRequest import MassExtendRenewalDateRequest +from appstoreserverlibrary.models.NotificationHistoryRequest import NotificationHistoryRequest +from appstoreserverlibrary.models.NotificationHistoryResponseItem import NotificationHistoryResponseItem +from appstoreserverlibrary.models.NotificationTypeV2 import NotificationTypeV2 +from appstoreserverlibrary.models.OfferType import OfferType +from appstoreserverlibrary.models.OrderLookupStatus import OrderLookupStatus +from appstoreserverlibrary.models.Platform import Platform +from appstoreserverlibrary.models.PlayTime import PlayTime +from appstoreserverlibrary.models.PriceIncreaseStatus import PriceIncreaseStatus +from appstoreserverlibrary.models.RevocationReason import RevocationReason +from appstoreserverlibrary.models.SendAttemptItem import SendAttemptItem +from appstoreserverlibrary.models.SendAttemptResult import SendAttemptResult +from appstoreserverlibrary.models.Status import Status +from appstoreserverlibrary.models.SubscriptionGroupIdentifierItem import SubscriptionGroupIdentifierItem +from appstoreserverlibrary.models.Subtype import Subtype +from appstoreserverlibrary.models.TransactionHistoryRequest import Order, ProductType, TransactionHistoryRequest +from appstoreserverlibrary.models.TransactionReason import TransactionReason +from appstoreserverlibrary.models.Type import Type +from appstoreserverlibrary.models.UserStatus import UserStatus + +from tests.util import decode_json_from_signed_date, read_data_from_binary_file, read_data_from_file + +from io import BytesIO + +class DecodedPayloads(unittest.TestCase): + def test_extend_renewal_date_for_all_active_subscribers(self): + client = self.get_client_with_body_from_file('tests/resources/models/extendRenewalDateForAllActiveSubscribersResponse.json', + 'POST', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions/extend/mass', + {}, + {'extendByDays': 45, 'extendReasonCode': 1, 'requestIdentifier': 'fdf964a4-233b-486c-aac1-97d8d52688ac', 'storefrontCountryCodes': ['USA', 'MEX'], 'productId': 'com.example.productId'}) + + extend_renewal_date_request = MassExtendRenewalDateRequest( + extendByDays=45, + extendReasonCode=ExtendReasonCode.CUSTOMER_SATISFACTION, + requestIdentifier='fdf964a4-233b-486c-aac1-97d8d52688ac', + storefrontCountryCodes=['USA', 'MEX'], + productId='com.example.productId') + + mass_extend_renewal_date_response = client.extend_renewal_date_for_all_active_subscribers(extend_renewal_date_request) + + self.assertIsNotNone(mass_extend_renewal_date_response) + self.assertEqual('758883e8-151b-47b7-abd0-60c4d804c2f5', mass_extend_renewal_date_response.requestIdentifier) + + def test_extend_subscription_renewal_date(self): + client = self.get_client_with_body_from_file('tests/resources/models/extendSubscriptionRenewalDateResponse.json', + 'PUT', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions/extend/4124214', + {}, + {'extendByDays': 45, 'extendReasonCode': 1, 'requestIdentifier': 'fdf964a4-233b-486c-aac1-97d8d52688ac'}) + + extend_renewal_date_request = ExtendRenewalDateRequest( + extendByDays=45, + extendReasonCode=ExtendReasonCode.CUSTOMER_SATISFACTION, + requestIdentifier='fdf964a4-233b-486c-aac1-97d8d52688ac' + ) + + extend_renewal_date_response = client.extend_subscription_renewal_date('4124214', extend_renewal_date_request) + + self.assertIsNotNone(extend_renewal_date_response) + self.assertEqual('2312412', extend_renewal_date_response.originalTransactionId) + self.assertEqual('9993', extend_renewal_date_response.webOrderLineItemId) + self.assertTrue(extend_renewal_date_response.success) + self.assertEqual(1698148900000, extend_renewal_date_response.effectiveDate) + + def test_get_all_subscription_statuses(self): + client = self.get_client_with_body_from_file('tests/resources/models/getAllSubscriptionStatusesResponse.json', + 'GET', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions/4321', + {'status': [2, 1]}, + None) + + status_response = client.get_all_subscription_statuses('4321', [Status.EXPIRED, Status.ACTIVE]) + + self.assertIsNotNone(status_response) + self.assertEqual(Environment.LOCAL_TESTING, status_response.environment) + self.assertEqual('LocalTesting', status_response.rawEnvironment) + self.assertEqual('com.example', status_response.bundleId) + self.assertEqual(5454545, status_response.appAppleId) + + + expected_body = [ + SubscriptionGroupIdentifierItem( + subscriptionGroupIdentifier='sub_group_one', + lastTransactions=[ + LastTransactionsItem( + status=Status.ACTIVE, + originalTransactionId='3749183', + signedTransactionInfo='signed_transaction_one', + signedRenewalInfo='signed_renewal_one' + ), + LastTransactionsItem( + status=Status.REVOKED, + originalTransactionId='5314314134', + signedTransactionInfo='signed_transaction_two', + signedRenewalInfo='signed_renewal_two' + ) + ] + ), + SubscriptionGroupIdentifierItem( + subscriptionGroupIdentifier='sub_group_two', + lastTransactions=[ + LastTransactionsItem( + status=Status.EXPIRED, + originalTransactionId='3413453', + signedTransactionInfo='signed_transaction_three', + signedRenewalInfo='signed_renewal_three' + ) + ] + ) + ] + self.assertEqual(expected_body, status_response.data) + + def test_get_refund_history(self): + client = self.get_client_with_body_from_file('tests/resources/models/getRefundHistoryResponse.json', + 'GET', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v2/refund/lookup/555555', + {'revision': ['revision_input']}, + None) + + refund_history_response = client.get_refund_history('555555', 'revision_input') + + self.assertIsNotNone(refund_history_response) + self.assertEqual(['signed_transaction_one', 'signed_transaction_two'], refund_history_response.signedTransactions) + self.assertEqual('revision_output', refund_history_response.revision) + self.assertTrue(refund_history_response.hasMore) + + def test_get_status_of_subscription_renewal_date_extensions(self): + client = self.get_client_with_body_from_file('tests/resources/models/getStatusOfSubscriptionRenewalDateExtensionsResponse.json', + 'GET', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions/extend/mass/20fba8a0-2b80-4a7d-a17f-85c1854727f8/com.example.product', + {}, + None) + + mass_extend_renewal_date_status_response = client.get_status_of_subscription_renewal_date_extensions('com.example.product', '20fba8a0-2b80-4a7d-a17f-85c1854727f8') + + self.assertIsNotNone(mass_extend_renewal_date_status_response) + self.assertEqual('20fba8a0-2b80-4a7d-a17f-85c1854727f8', mass_extend_renewal_date_status_response.requestIdentifier) + self.assertTrue(mass_extend_renewal_date_status_response.complete) + self.assertEqual(1698148900000, mass_extend_renewal_date_status_response.completeDate) + self.assertEqual(30, mass_extend_renewal_date_status_response.succeededCount) + self.assertEqual(2, mass_extend_renewal_date_status_response.failedCount) + + def test_get_test_notification_status(self): + client = self.get_client_with_body_from_file('tests/resources/models/getTestNotificationStatusResponse.json', + 'GET', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/test/8cd2974c-f905-492a-bf9a-b2f47c791d19', + {}, + None) + + check_test_notification_response = client.get_test_notification_status('8cd2974c-f905-492a-bf9a-b2f47c791d19') + + self.assertIsNotNone(check_test_notification_response) + self.assertEqual('signed_payload', check_test_notification_response.signedPayload) + sendAttemptItems = [ + SendAttemptItem(attemptDate=1698148900000,sendAttemptResult=SendAttemptResult.NO_RESPONSE), + SendAttemptItem(attemptDate=1698148950000,sendAttemptResult=SendAttemptResult.SUCCESS) + ] + self.assertEqual(sendAttemptItems, check_test_notification_response.sendAttempts) + + def test_get_notification_history(self): + client = self.get_client_with_body_from_file('tests/resources/models/getNotificationHistoryResponse.json', + 'POST', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/history', + {'paginationToken': ['a036bc0e-52b8-4bee-82fc-8c24cb6715d6']}, + {'startDate': 1698148900000, 'endDate': 1698148950000, 'notificationType': 'SUBSCRIBED', 'notificationSubtype': 'INITIAL_BUY', 'transactionId': '999733843', 'onlyFailures': True}) + + notification_history_request = NotificationHistoryRequest( + startDate=1698148900000, + endDate=1698148950000, + notificationType=NotificationTypeV2.SUBSCRIBED, + notificationSubtype=Subtype.INITIAL_BUY, + transactionId='999733843', + onlyFailures=True + ) + + notification_history_response = client.get_notification_history('a036bc0e-52b8-4bee-82fc-8c24cb6715d6', notification_history_request) + + self.assertIsNotNone(notification_history_response) + self.assertEqual('57715481-805a-4283-8499-1c19b5d6b20a', notification_history_response.paginationToken) + self.assertTrue(notification_history_response.hasMore) + expected_notification_history = [ + NotificationHistoryResponseItem(sendAttempts=[ + SendAttemptItem( + attemptDate=1698148900000, + sendAttemptResult=SendAttemptResult.NO_RESPONSE + ), + SendAttemptItem( + attemptDate=1698148950000, + rawSendAttemptResult='SUCCESS' + ) + ], signedPayload='signed_payload_one'), + NotificationHistoryResponseItem(sendAttempts=[ + SendAttemptItem( + attemptDate=1698148800000, + sendAttemptResult=SendAttemptResult.CIRCULAR_REDIRECT + ) + ], signedPayload='signed_payload_two') + ] + self.assertEqual(expected_notification_history, notification_history_response.notificationHistory) + + def test_get_transaction_history(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponse.json', + 'GET', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/history/1234', + {'revision': ['revision_input'], + 'startDate': ['123455'], + 'endDate': ['123456'], + 'productId': ['com.example.1', 'com.example.2'], + 'productType': ['CONSUMABLE', 'AUTO_RENEWABLE'], + 'sort': ['ASCENDING'], + 'subscriptionGroupIdentifier': ['sub_group_id', 'sub_group_id_2'], + 'inAppOwnershipType': ['FAMILY_SHARED'], + 'revoked': ['False']}, + None) + + request = TransactionHistoryRequest( + sort=Order.ASCENDING, + productTypes=[ProductType.CONSUMABLE, ProductType.AUTO_RENEWABLE], + endDate=123456, + startDate=123455, + revoked=False, + inAppOwnershipType=InAppOwnershipType.FAMILY_SHARED, + productIds=['com.example.1', 'com.example.2'], + subscriptionGroupIdentifiers=['sub_group_id', 'sub_group_id_2'] + ) + + history_response = client.get_transaction_history('1234', 'revision_input', request) + + self.assertIsNotNone(history_response) + self.assertEqual('revision_output', history_response.revision) + self.assertTrue(history_response.hasMore) + self.assertEqual('com.example', history_response.bundleId) + self.assertEqual(323232, history_response.appAppleId) + self.assertEqual(Environment.LOCAL_TESTING, history_response.environment) + self.assertEqual('LocalTesting', history_response.rawEnvironment) + self.assertEqual(['signed_transaction_value', 'signed_transaction_value2'], history_response.signedTransactions) + + def test_get_transaction_info(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionInfoResponse.json', + 'GET', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/1234', + {}, + None) + + transaction_info_response = client.get_transaction_info('1234') + + self.assertIsNotNone(transaction_info_response) + self.assertEqual('signed_transaction_info_value', transaction_info_response.signedTransactionInfo) + + def test_look_up_order_id(self): + client = self.get_client_with_body_from_file('tests/resources/models/lookupOrderIdResponse.json', + 'GET', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/lookup/W002182', + {}, + None) + + order_lookup_response = client.look_up_order_id('W002182') + + self.assertIsNotNone(order_lookup_response) + self.assertEqual(OrderLookupStatus.INVALID, order_lookup_response.status) + self.assertEqual(1, order_lookup_response.rawStatus) + self.assertEqual(['signed_transaction_one', 'signed_transaction_two'], order_lookup_response.signedTransactions) + + def test_request_test_notification(self): + client = self.get_client_with_body_from_file('tests/resources/models/requestTestNotificationResponse.json', + 'POST', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/test', + {}, + None) + + send_test_notification_response = client.request_test_notification() + + self.assertIsNotNone(send_test_notification_response) + self.assertEqual('ce3af791-365e-4c60-841b-1674b43c1609', send_test_notification_response.testNotificationToken) + + def test_send_consumption_data(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/consumption/49571273', + {}, + {'customerConsented': True, + 'consumptionStatus': 1, + 'platform': 2, + 'sampleContentProvided': False, + 'deliveryStatus': 3, + 'appAccountToken': '7389a31a-fb6d-4569-a2a6-db7d85d84813', + 'accountTenure': 4, + 'playTime': 5, + 'lifetimeDollarsRefunded': 6, + 'lifetimeDollarsPurchased': 7, + 'userStatus': 4}) + + consumptionRequest = ConsumptionRequest( + customerConsented=True, + consumptionStatus=ConsumptionStatus.NOT_CONSUMED, + platform=Platform.NON_APPLE, + sampleContentProvided=False, + deliveryStatus=DeliveryStatus.DID_NOT_DELIVER_DUE_TO_SERVER_OUTAGE, + appAccountToken='7389a31a-fb6d-4569-a2a6-db7d85d84813', + accountTenure=AccountTenure.THIRTY_DAYS_TO_NINETY_DAYS, + playTime=PlayTime.ONE_DAY_TO_FOUR_DAYS, + lifetimeDollarsRefunded=LifetimeDollarsRefunded.ONE_THOUSAND_DOLLARS_TO_ONE_THOUSAND_NINE_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS, + lifetimeDollarsPurchased=LifetimeDollarsPurchased.TWO_THOUSAND_DOLLARS_OR_GREATER, + userStatus=UserStatus.LIMITED_ACCESS + ) + + client.send_consumption_data('49571273', consumptionRequest) + + def test_api_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/apiException.json', + 'POST', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/test', + {}, + None, + 500) + try: + client.request_test_notification() + except APIException as e: + self.assertEqual(500, e.http_status_code) + self.assertEqual(5000000, e.raw_api_error) + self.assertEqual(APIError.GENERAL_INTERNAL, e.api_error) + return + + self.assertFalse(True) + + def test_api_too_many_requests(self): + client = self.get_client_with_body_from_file('tests/resources/models/apiTooManyRequestsException.json', + 'POST', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/test', + {}, + None, + 429) + try: + client.request_test_notification() + except APIException as e: + self.assertEqual(429, e.http_status_code) + self.assertEqual(4290000, e.raw_api_error) + self.assertEqual(APIError.RATE_LIMIT_EXCEEDED, e.api_error) + return + + self.assertFalse(True) + + def test_unknown_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/apiUnknownError.json', + 'POST', + 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/test', + {}, + None, + 400) + try: + client.request_test_notification() + except APIException as e: + self.assertEqual(400, e.http_status_code) + self.assertEqual(9990000, e.raw_api_error) + self.assertIsNone(e.api_error) + return + + self.assertFalse(True) + + def get_client_with_body(self, body: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + client = AppStoreServerAPIClient(signing_key, 'keyId', 'issuerId', 'com.example', Environment.LOCAL_TESTING) + def fake_execute_and_validate_inputs(method: bytes, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Dict[str, Any]): + self.assertEqual(expected_method, method) + self.assertEqual(expected_url, url) + self.assertEqual(expected_params, params) + self.assertEqual(['User-Agent', 'Authorization', 'Accept'], list(headers.keys())) + self.assertEqual('application/json', headers['Accept']) + self.assertTrue(headers['User-Agent'].startswith('app-store-server-library/python')) + self.assertTrue(headers['Authorization'].startswith('Bearer ')) + decoded_jwt = decode_json_from_signed_date(headers['Authorization'][7:]) + self.assertEqual('appstoreconnect-v1', decoded_jwt['payload']['aud']) + self.assertEqual('issuerId', decoded_jwt['payload']['iss']) + self.assertEqual('keyId', decoded_jwt['header']['kid']) + self.assertEqual('com.example', decoded_jwt['payload']['bid']) + self.assertEqual(expected_json, json) + response = Response() + response.status_code = status_code + response.raw = BytesIO(body) + response.headers['Content-Type'] = 'application/json' + return response + + client._execute_request = fake_execute_and_validate_inputs + return client + + def get_client_with_body_from_file(self, path: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200): + body = read_data_from_binary_file(path) + return self.get_client_with_body(body, expected_method, expected_url, expected_params, expected_json, status_code) diff --git a/tests/test_decoded_payloads.py b/tests/test_decoded_payloads.py new file mode 100644 index 0000000..57b12a1 --- /dev/null +++ b/tests/test_decoded_payloads.py @@ -0,0 +1,154 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +import unittest +from appstoreserverlibrary.models.AutoRenewStatus import AutoRenewStatus +from appstoreserverlibrary.models.Environment import Environment +from appstoreserverlibrary.models.ExpirationIntent import ExpirationIntent +from appstoreserverlibrary.models.InAppOwnershipType import InAppOwnershipType +from appstoreserverlibrary.models.NotificationTypeV2 import NotificationTypeV2 +from appstoreserverlibrary.models.OfferType import OfferType +from appstoreserverlibrary.models.PriceIncreaseStatus import PriceIncreaseStatus +from appstoreserverlibrary.models.RevocationReason import RevocationReason +from appstoreserverlibrary.models.Status import Status +from appstoreserverlibrary.models.Subtype import Subtype +from appstoreserverlibrary.models.TransactionReason import TransactionReason +from appstoreserverlibrary.models.Type import Type + +from tests.util import create_signed_data_from_json, get_default_signed_data_verifier + +class DecodedPayloads(unittest.TestCase): + def test_app_transaction_decoding(self): + signed_app_transaction = create_signed_data_from_json('tests/resources/models/appTransaction.json') + + signed_data_verifier = get_default_signed_data_verifier() + + app_transaction = signed_data_verifier.verify_and_decode_app_transaction(signed_app_transaction) + self.assertEqual(Environment.LOCAL_TESTING, app_transaction.receiptType) + self.assertEqual("LocalTesting", app_transaction.rawReceiptType) + self.assertEqual(531412, app_transaction.appAppleId) + self.assertEqual("com.example", app_transaction.bundleId) + self.assertEqual("1.2.3", app_transaction.applicationVersion) + self.assertEqual(512, app_transaction.versionExternalIdentifier) + self.assertEqual(1698148900000, app_transaction.receiptCreationDate) + self.assertEqual(1698148800000, app_transaction.originalPurchaseDate) + self.assertEqual("1.1.2", app_transaction.originalApplicationVersion) + self.assertEqual("device_verification_value", app_transaction.deviceVerification) + self.assertEqual("48ccfa42-7431-4f22-9908-7e88983e105a", app_transaction.deviceVerificationNonce) + self.assertEqual(1698148700000, app_transaction.preorderDate) + + def test_transaction_decoding(self): + signed_transaction = create_signed_data_from_json('tests/resources/models/signedTransaction.json') + + signed_data_verifier = get_default_signed_data_verifier() + + transaction = signed_data_verifier.verify_and_decode_signed_transaction(signed_transaction) + + self.assertEqual("12345", transaction.originalTransactionId) + self.assertEqual("23456", transaction.transactionId) + self.assertEqual("34343", transaction.webOrderLineItemId) + self.assertEqual("com.example", transaction.bundleId) + self.assertEqual("com.example.product", transaction.productId) + self.assertEqual("55555", transaction.subscriptionGroupIdentifier) + self.assertEqual(1698148800000, transaction.originalPurchaseDate) + self.assertEqual(1698148900000, transaction.purchaseDate) + self.assertEqual(1698148950000, transaction.revocationDate) + self.assertEqual(1698149000000, transaction.expiresDate) + self.assertEqual(1, transaction.quantity) + self.assertEqual(Type.AUTO_RENEWABLE_SUBSCRIPTION, transaction.type) + self.assertEqual("Auto-Renewable Subscription", transaction.rawType) + self.assertEqual("7e3fb20b-4cdb-47cc-936d-99d65f608138", transaction.appAccountToken) + self.assertEqual(InAppOwnershipType.PURCHASED, transaction.inAppOwnershipType) + self.assertEqual("PURCHASED", transaction.rawInAppOwnershipType) + self.assertEqual(1698148900000, transaction.signedDate) + self.assertEqual(RevocationReason.REFUNDED_DUE_TO_ISSUE, transaction.revocationReason) + self.assertEqual(1, transaction.rawRevocationReason) + self.assertEqual("abc.123", transaction.offerIdentifier) + self.assertTrue(transaction.isUpgraded) + self.assertEqual(OfferType.INTRODUCTORY_OFFER, transaction.offerType) + self.assertEqual(1, transaction.rawOfferType) + self.assertEqual("USA", transaction.storefront) + self.assertEqual("143441", transaction.storefrontId) + self.assertEqual(TransactionReason.PURCHASE, transaction.transactionReason) + self.assertEqual("PURCHASE", transaction.rawTransactionReason) + self.assertEqual(Environment.LOCAL_TESTING, transaction.environment) + self.assertEqual("LocalTesting", transaction.rawEnvironment) + + + def test_renewal_info_decoding(self): + signed_renewal_info = create_signed_data_from_json('tests/resources/models/signedRenewalInfo.json') + + signed_data_verifier = get_default_signed_data_verifier() + + renewal_info = signed_data_verifier.verify_and_decode_renewal_info(signed_renewal_info) + + self.assertEqual(ExpirationIntent.CUSTOMER_CANCELLED, renewal_info.expirationIntent) + self.assertEqual(1, renewal_info.rawExpirationIntent) + self.assertEqual("12345", renewal_info.originalTransactionId) + self.assertEqual("com.example.product.2", renewal_info.autoRenewProductId) + self.assertEqual("com.example.product", renewal_info.productId) + self.assertEqual(AutoRenewStatus.ON, renewal_info.autoRenewStatus) + self.assertEqual(1, renewal_info.rawAutoRenewStatus) + self.assertTrue(renewal_info.isInBillingRetryPeriod) + self.assertEqual(PriceIncreaseStatus.CUSTOMER_HAS_NOT_RESPONDED, renewal_info.priceIncreaseStatus) + self.assertEqual(0, renewal_info.rawPriceIncreaseStatus) + self.assertEqual(1698148900000, renewal_info.gracePeriodExpiresDate) + self.assertEqual(OfferType.PROMOTIONAL_OFFER, renewal_info.offerType) + self.assertEqual(2, renewal_info.rawOfferType) + self.assertEqual("abc.123", renewal_info.offerIdentifier) + self.assertEqual(1698148800000, renewal_info.signedDate) + self.assertEqual(Environment.LOCAL_TESTING, renewal_info.environment) + self.assertEqual("LocalTesting", renewal_info.rawEnvironment) + self.assertEqual(1698148800000, renewal_info.recentSubscriptionStartDate) + self.assertEqual(1698148850000, renewal_info.renewalDate) + + def test_notificaiton_decoding(self): + signed_notification = create_signed_data_from_json('tests/resources/models/signedNotification.json') + + signed_data_verifier = get_default_signed_data_verifier() + + notification = signed_data_verifier.verify_and_decode_notification(signed_notification) + + self.assertEqual(NotificationTypeV2.SUBSCRIBED, notification.notificationType) + self.assertEqual("SUBSCRIBED", notification.rawNotificationType) + self.assertEqual(Subtype.INITIAL_BUY, notification.subtype) + self.assertEqual("INITIAL_BUY", notification.rawSubtype) + self.assertEqual("002e14d5-51f5-4503-b5a8-c3a1af68eb20", notification.notificationUUID) + self.assertEqual("2.0", notification.version) + self.assertEqual(1698148900000, notification.signedDate) + self.assertIsNotNone(notification.data) + self.assertIsNone(notification.summary) + self.assertEqual(Environment.LOCAL_TESTING, notification.data.environment) + self.assertEqual("LocalTesting", notification.data.rawEnvironment) + self.assertEqual(41234, notification.data.appAppleId) + self.assertEqual("com.example", notification.data.bundleId) + self.assertEqual("1.2.3", notification.data.bundleVersion) + self.assertEqual("signed_transaction_info_value", notification.data.signedTransactionInfo) + self.assertEqual("signed_renewal_info_value", notification.data.signedRenewalInfo); + self.assertEqual(Status.ACTIVE, notification.data.status) + self.assertEqual(1, notification.data.rawStatus) + + def test_summary_notification_decoding(self): + signed_summary_notification = create_signed_data_from_json('tests/resources/models/signedSummaryNotification.json') + + signed_data_verifier = get_default_signed_data_verifier() + + notification = signed_data_verifier.verify_and_decode_notification(signed_summary_notification) + + self.assertEqual(NotificationTypeV2.RENEWAL_EXTENSION, notification.notificationType) + self.assertEqual("RENEWAL_EXTENSION", notification.rawNotificationType) + self.assertEqual(Subtype.SUMMARY, notification.subtype) + self.assertEqual("SUMMARY", notification.rawSubtype) + self.assertEqual("002e14d5-51f5-4503-b5a8-c3a1af68eb20", notification.notificationUUID) + self.assertEqual("2.0", notification.version) + self.assertEqual(1698148900000, notification.signedDate) + self.assertIsNone(notification.data) + self.assertIsNotNone(notification.summary) + self.assertEqual(Environment.LOCAL_TESTING, notification.summary.environment) + self.assertEqual("LocalTesting", notification.summary.rawEnvironment) + self.assertEqual(41234, notification.summary.appAppleId) + self.assertEqual("com.example", notification.summary.bundleId) + self.assertEqual("com.example.product", notification.summary.productId) + self.assertEqual("efb27071-45a4-4aca-9854-2a1e9146f265", notification.summary.requestIdentifier) + self.assertEqual(["CAN", "USA", "MEX"], notification.summary.storefrontCountryCodes) + self.assertEqual(5, notification.summary.succeededCount) + self.assertEqual(2, notification.summary.failedCount) \ No newline at end of file diff --git a/tests/test_payload_verification.py b/tests/test_payload_verification.py index d09acd7..331d745 100644 --- a/tests/test_payload_verification.py +++ b/tests/test_payload_verification.py @@ -7,73 +7,66 @@ from appstoreserverlibrary.signed_data_verifier import VerificationException, VerificationStatus, SignedDataVerifier -ROOT_CA_BASE64_ENCODED = "MIIBgjCCASmgAwIBAgIJALUc5ALiH5pbMAoGCCqGSM49BAMDMDYxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRIwEAYDVQQHDAlDdXBlcnRpbm8wHhcNMjMwMTA1MjEzMDIyWhcNMzMwMTAyMjEzMDIyWjA2MQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTESMBAGA1UEBwwJQ3VwZXJ0aW5vMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEc+/Bl+gospo6tf9Z7io5tdKdrlN1YdVnqEhEDXDShzdAJPQijamXIMHf8xWWTa1zgoYTxOKpbuJtDplz1XriTaMgMB4wDAYDVR0TBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDRwAwRAIgemWQXnMAdTad2JDJWng9U4uBBL5mA7WI05H7oH7c6iQCIHiRqMjNfzUAyiu9h6rOU/K+iTR0I/3Y/NSWsXHX+acc" - -TEST_NOTIFICATION = "eyJ4NWMiOlsiTUlJQm9EQ0NBVWFnQXdJQkFnSUJDekFLQmdncWhrak9QUVFEQWpCTk1Rc3dDUVlEVlFRR0V3SlZVekVUTUJFR0ExVUVDQXdLUTJGc2FXWnZjbTVwWVRFU01CQUdBMVVFQnd3SlEzVndaWEowYVc1dk1SVXdFd1lEVlFRS0RBeEpiblJsY20xbFpHbGhkR1V3SGhjTk1qTXdNVEEwTVRZek56TXhXaGNOTXpJeE1qTXhNVFl6TnpNeFdqQkZNUXN3Q1FZRFZRUUdFd0pWVXpFVE1CRUdBMVVFQ0F3S1EyRnNhV1p2Y201cFlURVNNQkFHQTFVRUJ3d0pRM1Z3WlhKMGFXNXZNUTB3Q3dZRFZRUUtEQVJNWldGbU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRTRyV0J4R21GYm5QSVBRSTB6c0JLekx4c2o4cEQydnFicjB5UElTVXgyV1F5eG1yTnFsOWZoSzhZRUV5WUZWNysrcDVpNFlVU1Ivbzl1UUlnQ1BJaHJLTWZNQjB3Q1FZRFZSMFRCQUl3QURBUUJnb3Foa2lHOTJOa0Jnc0JCQUlUQURBS0JnZ3Foa2pPUFFRREFnTklBREJGQWlFQWtpRVprb0ZNa2o0Z1huK1E5alhRWk1qWjJnbmpaM2FNOE5ZcmdmVFVpdlFDSURKWVowRmFMZTduU0lVMkxXTFRrNXRYVENjNEU4R0pTWWYvc1lSeEVGaWUiLCJNSUlCbHpDQ0FUMmdBd0lCQWdJQkJqQUtCZ2dxaGtqT1BRUURBakEyTVFzd0NRWURWUVFHRXdKVlV6RVRNQkVHQTFVRUNBd0tRMkZzYVdadmNtNXBZVEVTTUJBR0ExVUVCd3dKUTNWd1pYSjBhVzV2TUI0WERUSXpNREV3TkRFMk1qWXdNVm9YRFRNeU1USXpNVEUyTWpZd01Wb3dUVEVMTUFrR0ExVUVCaE1DVlZNeEV6QVJCZ05WQkFnTUNrTmhiR2xtYjNKdWFXRXhFakFRQmdOVkJBY01DVU4xY0dWeWRHbHViekVWTUJNR0ExVUVDZ3dNU1c1MFpYSnRaV1JwWVhSbE1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRUZRM2xYMnNxTjlHSXdBaWlNUURRQy9reW5TZ1g0N1J3dmlET3RNWFh2eUtkUWU2Q1BzUzNqbzJ1UkR1RXFBeFdlT2lDcmpsRFdzeXo1d3dkVTBndGFxTWxNQ013RHdZRFZSMFRCQWd3QmdFQi93SUJBREFRQmdvcWhraUc5Mk5rQmdJQkJBSVRBREFLQmdncWhrak9QUVFEQWdOSUFEQkZBaUVBdm56TWNWMjY4Y1JiMS9GcHlWMUVoVDNXRnZPenJCVVdQNi9Ub1RoRmF2TUNJRmJhNXQ2WUt5MFIySkR0eHF0T2pKeTY2bDZWN2QvUHJBRE5wa21JUFcraSIsIk1JSUJYRENDQVFJQ0NRQ2ZqVFVHTERuUjlqQUtCZ2dxaGtqT1BRUURBekEyTVFzd0NRWURWUVFHRXdKVlV6RVRNQkVHQTFVRUNBd0tRMkZzYVdadmNtNXBZVEVTTUJBR0ExVUVCd3dKUTNWd1pYSjBhVzV2TUI0WERUSXpNREV3TkRFMk1qQXpNbG9YRFRNek1ERXdNVEUyTWpBek1sb3dOakVMTUFrR0ExVUVCaE1DVlZNeEV6QVJCZ05WQkFnTUNrTmhiR2xtYjNKdWFXRXhFakFRQmdOVkJBY01DVU4xY0dWeWRHbHViekJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCSFB2d1pmb0tMS2FPclgvV2U0cU9iWFNuYTVUZFdIVlo2aElSQTF3MG9jM1FDVDBJbzJwbHlEQjMvTVZsazJ0YzRLR0U4VGlxVzdpYlE2WmM5VjY0azB3Q2dZSUtvWkl6ajBFQXdNRFNBQXdSUUloQU1USGhXdGJBUU4waFN4SVhjUDRDS3JEQ0gvZ3N4V3B4NmpUWkxUZVorRlBBaUIzNW53azVxMHpjSXBlZnZZSjBNVS95R0dIU1dlejBicTBwRFlVTy9ubUR3PT0iXSwidHlwIjoiSldUIiwiYWxnIjoiRVMyNTYifQ.eyJkYXRhIjp7ImFwcEFwcGxlSWQiOjEyMzQsImVudmlyb25tZW50IjoiU2FuZGJveCIsImJ1bmRsZUlkIjoiY29tLmV4YW1wbGUifSwibm90aWZpY2F0aW9uVVVJRCI6IjlhZDU2YmQyLTBiYzYtNDJlMC1hZjI0LWZkOTk2ZDg3YTFlNiIsInNpZ25lZERhdGUiOjE2ODEzMTQzMjQwMDAsIm5vdGlmaWNhdGlvblR5cGUiOiJURVNUIn0.VVXYwuNm2Y3XsOUva-BozqatRCsDuykA7xIe_CCRw6aIAAxJ1nb2sw871jfZ6dcgNhUuhoZ93hfbc1v_5zB7Og" -MISSING_X5C_HEADER_CLAIM = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsIng1Y3dyb25nIjpbIk1JSUJvRENDQVVhZ0F3SUJBZ0lCRERBS0JnZ3Foa2pPUFFRREF6QkZNUXN3Q1FZRFZRUUdFd0pWVXpFTE1Ba0dBMVVFQ0F3Q1EwRXhFakFRQmdOVkJBY01DVU4xY0dWeWRHbHViekVWTUJNR0ExVUVDZ3dNU1c1MFpYSnRaV1JwWVhSbE1CNFhEVEl6TURFd05USXhNekV6TkZvWERUTXpNREV3TVRJeE16RXpORm93UFRFTE1Ba0dBMVVFQmhNQ1ZWTXhDekFKQmdOVkJBZ01Ba05CTVJJd0VBWURWUVFIREFsRGRYQmxjblJwYm04eERUQUxCZ05WQkFvTUJFeGxZV1l3V1RBVEJnY3Foa2pPUFFJQkJnZ3Foa2pPUFFNQkJ3TkNBQVRpdFlIRWFZVnVjOGc5QWpUT3dFck12R3lQeWtQYStwdXZUSThoSlRIWlpETEdhczJxWDErRXJ4Z1FUSmdWWHY3Nm5tTGhoUkpIK2oyNUFpQUk4aUdzb3k4d0xUQUpCZ05WSFJNRUFqQUFNQTRHQTFVZER3RUIvd1FFQXdJSGdEQVFCZ29xaGtpRzkyTmtCZ3NCQkFJRkFEQUtCZ2dxaGtqT1BRUURBd05JQURCRkFpQlg0YytUMEZwNW5KNVFSQ2xSZnU1UFNCeVJ2TlB0dWFUc2swdlBCM1dBSUFJaEFOZ2FhdUFqL1lQOXMwQWtFaHlKaHhRTy82UTJ6b3VaK0gxQ0lPZWhuTXpRIiwiTUlJQm56Q0NBVVdnQXdJQkFnSUJDekFLQmdncWhrak9QUVFEQXpBMk1Rc3dDUVlEVlFRR0V3SlZVekVUTUJFR0ExVUVDQXdLUTJGc2FXWnZjbTVwWVRFU01CQUdBMVVFQnd3SlEzVndaWEowYVc1dk1CNFhEVEl6TURFd05USXhNekV3TlZvWERUTXpNREV3TVRJeE16RXdOVm93UlRFTE1Ba0dBMVVFQmhNQ1ZWTXhDekFKQmdOVkJBZ01Ba05CTVJJd0VBWURWUVFIREFsRGRYQmxjblJwYm04eEZUQVRCZ05WQkFvTURFbHVkR1Z5YldWa2FXRjBaVEJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCQlVONVY5cktqZlJpTUFJb2pFQTBBdjVNcDBvRitPMGNMNGd6clRGMTc4aW5VSHVnajdFdDQ2TnJrUTdoS2dNVm5qb2dxNDVRMXJNcytjTUhWTklMV3FqTlRBek1BOEdBMVVkRXdRSU1BWUJBZjhDQVFBd0RnWURWUjBQQVFIL0JBUURBZ0VHTUJBR0NpcUdTSWIzWTJRR0FnRUVBZ1VBTUFvR0NDcUdTTTQ5QkFNREEwZ0FNRVVDSVFDbXNJS1lzNDF1bGxzc0hYNHJWdmVVVDBaN0lzNS9oTEsxbEZQVHR1bjNoQUlnYzIrMlJHNStnTmNGVmNzK1hKZUVsNEdaK29qbDNST09tbGwreWU3ZHluUT0iLCJNSUlCZ2pDQ0FTbWdBd0lCQWdJSkFMVWM1QUxpSDVwYk1Bb0dDQ3FHU000OUJBTURNRFl4Q3pBSkJnTlZCQVlUQWxWVE1STXdFUVlEVlFRSURBcERZV3hwWm05eWJtbGhNUkl3RUFZRFZRUUhEQWxEZFhCbGNuUnBibTh3SGhjTk1qTXdNVEExTWpFek1ESXlXaGNOTXpNd01UQXlNakV6TURJeVdqQTJNUXN3Q1FZRFZRUUdFd0pWVXpFVE1CRUdBMVVFQ0F3S1EyRnNhV1p2Y201cFlURVNNQkFHQTFVRUJ3d0pRM1Z3WlhKMGFXNXZNRmt3RXdZSEtvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVjKy9CbCtnb3NwbzZ0ZjlaN2lvNXRkS2RybE4xWWRWbnFFaEVEWERTaHpkQUpQUWlqYW1YSU1IZjh4V1dUYTF6Z29ZVHhPS3BidUp0RHBsejFYcmlUYU1nTUI0d0RBWURWUjBUQkFVd0F3RUIvekFPQmdOVkhROEJBZjhFQkFNQ0FRWXdDZ1lJS29aSXpqMEVBd01EUndBd1JBSWdlbVdRWG5NQWRUYWQySkRKV25nOVU0dUJCTDVtQTdXSTA1SDdvSDdjNmlRQ0lIaVJxTWpOZnpVQXlpdTloNnJPVS9LK2lUUjBJLzNZL05TV3NYSFgrYWNjIl19.eyJkYXRhIjp7ImJ1bmRsZUlkIjoiY29tLmV4YW1wbGUifSwibm90aWZpY2F0aW9uVVVJRCI6IjlhZDU2YmQyLTBiYzYtNDJlMC1hZjI0LWZkOTk2ZDg3YTFlNiIsIm5vdGlmaWNhdGlvblR5cGUiOiJURVNUIn0.1TFhjDR4WwQJNgizVGYXz3WE3ajxTdH1wKLQQ71MtrkadSxxOo3yPo_6L9Z03unIU7YK-NRNzSIb5bh5WqTprQ" -WRONG_BUNDLE_ID = "eyJ4NWMiOlsiTUlJQm9EQ0NBVWFnQXdJQkFnSUJEREFLQmdncWhrak9QUVFEQXpCRk1Rc3dDUVlEVlFRR0V3SlZVekVMTUFrR0ExVUVDQXdDUTBFeEVqQVFCZ05WQkFjTUNVTjFjR1Z5ZEdsdWJ6RVZNQk1HQTFVRUNnd01TVzUwWlhKdFpXUnBZWFJsTUI0WERUSXpNREV3TlRJeE16RXpORm9YRFRNek1ERXdNVEl4TXpFek5Gb3dQVEVMTUFrR0ExVUVCaE1DVlZNeEN6QUpCZ05WQkFnTUFrTkJNUkl3RUFZRFZRUUhEQWxEZFhCbGNuUnBibTh4RFRBTEJnTlZCQW9NQkV4bFlXWXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CQndOQ0FBVGl0WUhFYVlWdWM4ZzlBalRPd0VyTXZHeVB5a1BhK3B1dlRJOGhKVEhaWkRMR2FzMnFYMStFcnhnUVRKZ1ZYdjc2bm1MaGhSSkgrajI1QWlBSThpR3NveTh3TFRBSkJnTlZIUk1FQWpBQU1BNEdBMVVkRHdFQi93UUVBd0lIZ0RBUUJnb3Foa2lHOTJOa0Jnc0JCQUlGQURBS0JnZ3Foa2pPUFFRREF3TklBREJGQWlCWDRjK1QwRnA1bko1UVJDbFJmdTVQU0J5UnZOUHR1YVRzazB2UEIzV0FJQUloQU5nYWF1QWovWVA5czBBa0VoeUpoeFFPLzZRMnpvdVorSDFDSU9laG5NelEiLCJNSUlCbnpDQ0FVV2dBd0lCQWdJQkN6QUtCZ2dxaGtqT1BRUURBekEyTVFzd0NRWURWUVFHRXdKVlV6RVRNQkVHQTFVRUNBd0tRMkZzYVdadmNtNXBZVEVTTUJBR0ExVUVCd3dKUTNWd1pYSjBhVzV2TUI0WERUSXpNREV3TlRJeE16RXdOVm9YRFRNek1ERXdNVEl4TXpFd05Wb3dSVEVMTUFrR0ExVUVCaE1DVlZNeEN6QUpCZ05WQkFnTUFrTkJNUkl3RUFZRFZRUUhEQWxEZFhCbGNuUnBibTh4RlRBVEJnTlZCQW9NREVsdWRHVnliV1ZrYVdGMFpUQlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDlBd0VIQTBJQUJCVU41VjlyS2pmUmlNQUlvakVBMEF2NU1wMG9GK08wY0w0Z3pyVEYxNzhpblVIdWdqN0V0NDZOcmtRN2hLZ01WbmpvZ3E0NVExck1zK2NNSFZOSUxXcWpOVEF6TUE4R0ExVWRFd1FJTUFZQkFmOENBUUF3RGdZRFZSMFBBUUgvQkFRREFnRUdNQkFHQ2lxR1NJYjNZMlFHQWdFRUFnVUFNQW9HQ0NxR1NNNDlCQU1EQTBnQU1FVUNJUUNtc0lLWXM0MXVsbHNzSFg0clZ2ZVVUMFo3SXM1L2hMSzFsRlBUdHVuM2hBSWdjMisyUkc1K2dOY0ZWY3MrWEplRWw0R1orb2psM1JPT21sbCt5ZTdkeW5RPSIsIk1JSUJnakNDQVNtZ0F3SUJBZ0lKQUxVYzVBTGlINXBiTUFvR0NDcUdTTTQ5QkFNRE1EWXhDekFKQmdOVkJBWVRBbFZUTVJNd0VRWURWUVFJREFwRFlXeHBabTl5Ym1saE1SSXdFQVlEVlFRSERBbERkWEJsY25ScGJtOHdIaGNOTWpNd01UQTFNakV6TURJeVdoY05Nek13TVRBeU1qRXpNREl5V2pBMk1Rc3dDUVlEVlFRR0V3SlZVekVUTUJFR0ExVUVDQXdLUTJGc2FXWnZjbTVwWVRFU01CQUdBMVVFQnd3SlEzVndaWEowYVc1dk1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRWMrL0JsK2dvc3BvNnRmOVo3aW81dGRLZHJsTjFZZFZucUVoRURYRFNoemRBSlBRaWphbVhJTUhmOHhXV1RhMXpnb1lUeE9LcGJ1SnREcGx6MVhyaVRhTWdNQjR3REFZRFZSMFRCQVV3QXdFQi96QU9CZ05WSFE4QkFmOEVCQU1DQVFZd0NnWUlLb1pJemowRUF3TURSd0F3UkFJZ2VtV1FYbk1BZFRhZDJKREpXbmc5VTR1QkJMNW1BN1dJMDVIN29IN2M2aVFDSUhpUnFNak5melVBeWl1OWg2ck9VL0sraVRSMEkvM1kvTlNXc1hIWCthY2MiXSwidHlwIjoiSldUIiwiYWxnIjoiRVMyNTYifQ.eyJkYXRhIjp7ImJ1bmRsZUlkIjoiY29tLmV4YW1wbGUud3JvbmcifSwibm90aWZpY2F0aW9uVVVJRCI6IjlhZDU2YmQyLTBiYzYtNDJlMC1hZjI0LWZkOTk2ZDg3YTFlNiIsIm5vdGlmaWNhdGlvblR5cGUiOiJURVNUIn0.WWE31hTB_mcv2O_lf-xI-MNY3d8txc0MzpqFx4QnYDfFIxB95Lo2Fm3r46YSjLLdL7xCWdEJrJP5bHgRCejAGg" -RENEWAL_INFO = "eyJ4NWMiOlsiTUlJQm9EQ0NBVWFnQXdJQkFnSUJEREFLQmdncWhrak9QUVFEQXpCRk1Rc3dDUVlEVlFRR0V3SlZVekVMTUFrR0ExVUVDQXdDUTBFeEVqQVFCZ05WQkFjTUNVTjFjR1Z5ZEdsdWJ6RVZNQk1HQTFVRUNnd01TVzUwWlhKdFpXUnBZWFJsTUI0WERUSXpNREV3TlRJeE16RXpORm9YRFRNek1ERXdNVEl4TXpFek5Gb3dQVEVMTUFrR0ExVUVCaE1DVlZNeEN6QUpCZ05WQkFnTUFrTkJNUkl3RUFZRFZRUUhEQWxEZFhCbGNuUnBibTh4RFRBTEJnTlZCQW9NQkV4bFlXWXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CQndOQ0FBVGl0WUhFYVlWdWM4ZzlBalRPd0VyTXZHeVB5a1BhK3B1dlRJOGhKVEhaWkRMR2FzMnFYMStFcnhnUVRKZ1ZYdjc2bm1MaGhSSkgrajI1QWlBSThpR3NveTh3TFRBSkJnTlZIUk1FQWpBQU1BNEdBMVVkRHdFQi93UUVBd0lIZ0RBUUJnb3Foa2lHOTJOa0Jnc0JCQUlGQURBS0JnZ3Foa2pPUFFRREF3TklBREJGQWlCWDRjK1QwRnA1bko1UVJDbFJmdTVQU0J5UnZOUHR1YVRzazB2UEIzV0FJQUloQU5nYWF1QWovWVA5czBBa0VoeUpoeFFPLzZRMnpvdVorSDFDSU9laG5NelEiLCJNSUlCbnpDQ0FVV2dBd0lCQWdJQkN6QUtCZ2dxaGtqT1BRUURBekEyTVFzd0NRWURWUVFHRXdKVlV6RVRNQkVHQTFVRUNBd0tRMkZzYVdadmNtNXBZVEVTTUJBR0ExVUVCd3dKUTNWd1pYSjBhVzV2TUI0WERUSXpNREV3TlRJeE16RXdOVm9YRFRNek1ERXdNVEl4TXpFd05Wb3dSVEVMTUFrR0ExVUVCaE1DVlZNeEN6QUpCZ05WQkFnTUFrTkJNUkl3RUFZRFZRUUhEQWxEZFhCbGNuUnBibTh4RlRBVEJnTlZCQW9NREVsdWRHVnliV1ZrYVdGMFpUQlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDlBd0VIQTBJQUJCVU41VjlyS2pmUmlNQUlvakVBMEF2NU1wMG9GK08wY0w0Z3pyVEYxNzhpblVIdWdqN0V0NDZOcmtRN2hLZ01WbmpvZ3E0NVExck1zK2NNSFZOSUxXcWpOVEF6TUE4R0ExVWRFd1FJTUFZQkFmOENBUUF3RGdZRFZSMFBBUUgvQkFRREFnRUdNQkFHQ2lxR1NJYjNZMlFHQWdFRUFnVUFNQW9HQ0NxR1NNNDlCQU1EQTBnQU1FVUNJUUNtc0lLWXM0MXVsbHNzSFg0clZ2ZVVUMFo3SXM1L2hMSzFsRlBUdHVuM2hBSWdjMisyUkc1K2dOY0ZWY3MrWEplRWw0R1orb2psM1JPT21sbCt5ZTdkeW5RPSIsIk1JSUJnakNDQVNtZ0F3SUJBZ0lKQUxVYzVBTGlINXBiTUFvR0NDcUdTTTQ5QkFNRE1EWXhDekFKQmdOVkJBWVRBbFZUTVJNd0VRWURWUVFJREFwRFlXeHBabTl5Ym1saE1SSXdFQVlEVlFRSERBbERkWEJsY25ScGJtOHdIaGNOTWpNd01UQTFNakV6TURJeVdoY05Nek13TVRBeU1qRXpNREl5V2pBMk1Rc3dDUVlEVlFRR0V3SlZVekVUTUJFR0ExVUVDQXdLUTJGc2FXWnZjbTVwWVRFU01CQUdBMVVFQnd3SlEzVndaWEowYVc1dk1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRWMrL0JsK2dvc3BvNnRmOVo3aW81dGRLZHJsTjFZZFZucUVoRURYRFNoemRBSlBRaWphbVhJTUhmOHhXV1RhMXpnb1lUeE9LcGJ1SnREcGx6MVhyaVRhTWdNQjR3REFZRFZSMFRCQVV3QXdFQi96QU9CZ05WSFE4QkFmOEVCQU1DQVFZd0NnWUlLb1pJemowRUF3TURSd0F3UkFJZ2VtV1FYbk1BZFRhZDJKREpXbmc5VTR1QkJMNW1BN1dJMDVIN29IN2M2aVFDSUhpUnFNak5melVBeWl1OWg2ck9VL0sraVRSMEkvM1kvTlNXc1hIWCthY2MiXSwidHlwIjoiSldUIiwiYWxnIjoiRVMyNTYifQ.eyJlbnZpcm9ubWVudCI6IlNhbmRib3giLCJzaWduZWREYXRlIjoxNjcyOTU2MTU0MDAwfQ.FbK2OL-t6l4892W7fzWyus_g9mIl2CzWLbVt7Kgcnt6zzVulF8bzovgpe0v_y490blROGixy8KDoe2dSU53-Xw" -TRANSACTION_INFO = "eyJ4NWMiOlsiTUlJQm9EQ0NBVWFnQXdJQkFnSUJDekFLQmdncWhrak9QUVFEQWpCTk1Rc3dDUVlEVlFRR0V3SlZVekVUTUJFR0ExVUVDQXdLUTJGc2FXWnZjbTVwWVRFU01CQUdBMVVFQnd3SlEzVndaWEowYVc1dk1SVXdFd1lEVlFRS0RBeEpiblJsY20xbFpHbGhkR1V3SGhjTk1qTXdNVEEwTVRZek56TXhXaGNOTXpJeE1qTXhNVFl6TnpNeFdqQkZNUXN3Q1FZRFZRUUdFd0pWVXpFVE1CRUdBMVVFQ0F3S1EyRnNhV1p2Y201cFlURVNNQkFHQTFVRUJ3d0pRM1Z3WlhKMGFXNXZNUTB3Q3dZRFZRUUtEQVJNWldGbU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRTRyV0J4R21GYm5QSVBRSTB6c0JLekx4c2o4cEQydnFicjB5UElTVXgyV1F5eG1yTnFsOWZoSzhZRUV5WUZWNysrcDVpNFlVU1Ivbzl1UUlnQ1BJaHJLTWZNQjB3Q1FZRFZSMFRCQUl3QURBUUJnb3Foa2lHOTJOa0Jnc0JCQUlUQURBS0JnZ3Foa2pPUFFRREFnTklBREJGQWlFQWtpRVprb0ZNa2o0Z1huK1E5alhRWk1qWjJnbmpaM2FNOE5ZcmdmVFVpdlFDSURKWVowRmFMZTduU0lVMkxXTFRrNXRYVENjNEU4R0pTWWYvc1lSeEVGaWUiLCJNSUlCbHpDQ0FUMmdBd0lCQWdJQkJqQUtCZ2dxaGtqT1BRUURBakEyTVFzd0NRWURWUVFHRXdKVlV6RVRNQkVHQTFVRUNBd0tRMkZzYVdadmNtNXBZVEVTTUJBR0ExVUVCd3dKUTNWd1pYSjBhVzV2TUI0WERUSXpNREV3TkRFMk1qWXdNVm9YRFRNeU1USXpNVEUyTWpZd01Wb3dUVEVMTUFrR0ExVUVCaE1DVlZNeEV6QVJCZ05WQkFnTUNrTmhiR2xtYjNKdWFXRXhFakFRQmdOVkJBY01DVU4xY0dWeWRHbHViekVWTUJNR0ExVUVDZ3dNU1c1MFpYSnRaV1JwWVhSbE1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRUZRM2xYMnNxTjlHSXdBaWlNUURRQy9reW5TZ1g0N1J3dmlET3RNWFh2eUtkUWU2Q1BzUzNqbzJ1UkR1RXFBeFdlT2lDcmpsRFdzeXo1d3dkVTBndGFxTWxNQ013RHdZRFZSMFRCQWd3QmdFQi93SUJBREFRQmdvcWhraUc5Mk5rQmdJQkJBSVRBREFLQmdncWhrak9QUVFEQWdOSUFEQkZBaUVBdm56TWNWMjY4Y1JiMS9GcHlWMUVoVDNXRnZPenJCVVdQNi9Ub1RoRmF2TUNJRmJhNXQ2WUt5MFIySkR0eHF0T2pKeTY2bDZWN2QvUHJBRE5wa21JUFcraSIsIk1JSUJYRENDQVFJQ0NRQ2ZqVFVHTERuUjlqQUtCZ2dxaGtqT1BRUURBekEyTVFzd0NRWURWUVFHRXdKVlV6RVRNQkVHQTFVRUNBd0tRMkZzYVdadmNtNXBZVEVTTUJBR0ExVUVCd3dKUTNWd1pYSjBhVzV2TUI0WERUSXpNREV3TkRFMk1qQXpNbG9YRFRNek1ERXdNVEUyTWpBek1sb3dOakVMTUFrR0ExVUVCaE1DVlZNeEV6QVJCZ05WQkFnTUNrTmhiR2xtYjNKdWFXRXhFakFRQmdOVkJBY01DVU4xY0dWeWRHbHViekJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCSFB2d1pmb0tMS2FPclgvV2U0cU9iWFNuYTVUZFdIVlo2aElSQTF3MG9jM1FDVDBJbzJwbHlEQjMvTVZsazJ0YzRLR0U4VGlxVzdpYlE2WmM5VjY0azB3Q2dZSUtvWkl6ajBFQXdNRFNBQXdSUUloQU1USGhXdGJBUU4waFN4SVhjUDRDS3JEQ0gvZ3N4V3B4NmpUWkxUZVorRlBBaUIzNW53azVxMHpjSXBlZnZZSjBNVS95R0dIU1dlejBicTBwRFlVTy9ubUR3PT0iXSwidHlwIjoiSldUIiwiYWxnIjoiRVMyNTYifQ.eyJlbnZpcm9ubWVudCI6IlNhbmRib3giLCJidW5kbGVJZCI6ImNvbS5leGFtcGxlIiwic2lnbmVkRGF0ZSI6MTY3Mjk1NjE1NDAwMH0.PnHWpeIJZ8f2Q218NSGLo_aR0IBEJvC6PxmxKXh-qfYTrZccx2suGl223OSNAX78e4Ylf2yJCG2N-FfU-NIhZQ" +from tests.util import get_signed_data_verifier, read_data_from_file class PayloadVerification(unittest.TestCase): - def test_app_store_server_notification_decoding(self): - verifier = self.get_payload_verifier() - notification = verifier.verify_and_decode_notification(TEST_NOTIFICATION) + verifier = get_signed_data_verifier(Environment.SANDBOX, "com.example") + test_notification = read_data_from_file('tests/resources/mock_signed_data/testNotification') + notification = verifier.verify_and_decode_notification(test_notification) self.assertEqual(notification.notificationType, NotificationTypeV2.TEST) def test_app_store_server_notification_decoding_production(self): - verifier = SignedDataVerifier([b64decode(ROOT_CA_BASE64_ENCODED)], False, Environment.PRODUCTION, "com.example", 1234) - verifier._chain_verifier.enable_strict_checks = False + verifier = get_signed_data_verifier(Environment.PRODUCTION, "com.example") + test_notification = read_data_from_file('tests/resources/mock_signed_data/testNotification') with self.assertRaises(VerificationException) as context: - verifier.verify_and_decode_notification(TEST_NOTIFICATION) + verifier.verify_and_decode_notification(test_notification) self.assertEqual(context.exception.status, VerificationStatus.INVALID_ENVIRONMENT) def test_missing_x5c_header(self): - verifier = self.get_payload_verifier() + verifier = get_signed_data_verifier(Environment.SANDBOX, "com.example") + missing_x5c_header_claim = read_data_from_file('tests/resources/mock_signed_data/missingX5CHeaderClaim') with self.assertRaises(VerificationException) as context: - verifier.verify_and_decode_notification(MISSING_X5C_HEADER_CLAIM) + verifier.verify_and_decode_notification(missing_x5c_header_claim) self.assertEqual(context.exception.status, VerificationStatus.VERIFICATION_FAILURE) def test_wrong_bundle_id_for_server_notification(self): - verifier = self.get_payload_verifier() + verifier = get_signed_data_verifier(Environment.SANDBOX, "com.examplex") + wrong_bundle = read_data_from_file('tests/resources/mock_signed_data/wrongBundleId') with self.assertRaises(VerificationException) as context: - verifier.verify_and_decode_notification(WRONG_BUNDLE_ID) + verifier.verify_and_decode_notification(wrong_bundle) self.assertEqual(context.exception.status, VerificationStatus.INVALID_APP_IDENTIFIER) def test_wrong_app_apple_id_for_server_notification(self): - verifier = SignedDataVerifier([b64decode(ROOT_CA_BASE64_ENCODED)], False, Environment.PRODUCTION, "com.example", 1235) - verifier._chain_verifier.enable_strict_checks = False + verifier = get_signed_data_verifier(Environment.PRODUCTION, "com.example", 1235) + test_notification = read_data_from_file('tests/resources/mock_signed_data/testNotification') with self.assertRaises(VerificationException) as context: - verifier.verify_and_decode_notification(TEST_NOTIFICATION) + verifier.verify_and_decode_notification(test_notification) self.assertEqual(context.exception.status, VerificationStatus.INVALID_APP_IDENTIFIER) def test_renewal_info_decoding(self): - verifier = self.get_payload_verifier() - notification = verifier.verify_and_decode_renewal_info(RENEWAL_INFO) + verifier = get_signed_data_verifier(Environment.SANDBOX, "com.example") + renewal_info = read_data_from_file('tests/resources/mock_signed_data/renewalInfo') + notification = verifier.verify_and_decode_renewal_info(renewal_info) self.assertEqual(notification.environment, Environment.SANDBOX) def test_transaction_info_decoding(self): - verifier = self.get_payload_verifier() - notification = verifier.verify_and_decode_signed_transaction(TRANSACTION_INFO) + verifier = get_signed_data_verifier(Environment.SANDBOX, "com.example") + transaction_info = read_data_from_file('tests/resources/mock_signed_data/transactionInfo') + notification = verifier.verify_and_decode_signed_transaction(transaction_info) self.assertEqual(notification.environment, Environment.SANDBOX) def test_malformed_jwt_with_too_many_parts(self): - verifier = self.get_payload_verifier() + verifier = get_signed_data_verifier(Environment.SANDBOX, "com.example") with self.assertRaises(VerificationException) as context: verifier.verify_and_decode_notification("a.b.c.d") self.assertEqual(context.exception.status, VerificationStatus.VERIFICATION_FAILURE) def test_malformed_jwt_with_malformed_data(self): - verifier = self.get_payload_verifier() + verifier = get_signed_data_verifier(Environment.SANDBOX, "com.example") with self.assertRaises(VerificationException) as context: verifier.verify_and_decode_notification("a.b.c") self.assertEqual(context.exception.status, VerificationStatus.VERIFICATION_FAILURE) - def get_payload_verifier(self) -> SignedDataVerifier: - verifier = SignedDataVerifier([b64decode(ROOT_CA_BASE64_ENCODED)], False, Environment.SANDBOX, "com.example") - verifier._chain_verifier.enable_strict_checks = False # We don't have authority identifiers on test certs - return verifier - if __name__ == '__main__': unittest.main() diff --git a/tests/test_promotional_offer_signature_creator.py b/tests/test_promotional_offer_signature_creator.py new file mode 100644 index 0000000..31cdb84 --- /dev/null +++ b/tests/test_promotional_offer_signature_creator.py @@ -0,0 +1,15 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +import unittest +from appstoreserverlibrary.promotional_offer import PromotionalOfferSignatureCreator + +from tests.util import read_data_from_binary_file +from uuid import UUID + + +class PromotionalOfferSignatureCreatorTest(unittest.TestCase): + def test_signature_creator(self): + signing_key = read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + signature_creator = PromotionalOfferSignatureCreator(signing_key, 'keyId', 'bundleId') + signature = signature_creator.create_signature("productId", "offerId", "applicationUsername", UUID("20fba8a0-2b80-4a7d-a17f-85c1854727f8"), 1698148900000) + self.assertIsNotNone(signature) \ No newline at end of file diff --git a/tests/test_xcode_signed_data.py b/tests/test_xcode_signed_data.py index ccc2ca2..c11d2b3 100644 --- a/tests/test_xcode_signed_data.py +++ b/tests/test_xcode_signed_data.py @@ -32,6 +32,7 @@ def test_xcode_signed_app_transaction(self): self.assertEqual("48c8b92d-ce0d-4229-bedf-e61b4f9cfc92", app_transaction.deviceVerificationNonce) self.assertIsNone(app_transaction.preorderDate) self.assertEqual(Environment.XCODE, app_transaction.receiptType) + self.assertEqual("Xcode", app_transaction.rawReceiptType) def test_xcode_signed_transaction(self): verifier = get_signed_data_verifier(Environment.XCODE, XCODE_BUNDLE_ID) @@ -50,18 +51,23 @@ def test_xcode_signed_transaction(self): self.assertEqual(1700358336049, transaction.expiresDate) self.assertEqual(1, transaction.quantity) self.assertEqual(Type.AUTO_RENEWABLE_SUBSCRIPTION, transaction.type) + self.assertEqual("Auto-Renewable Subscription", transaction.rawType) self.assertIsNone(transaction.appAccountToken) self.assertEqual(InAppOwnershipType.PURCHASED, transaction.inAppOwnershipType) + self.assertEqual("PURCHASED", transaction.rawInAppOwnershipType) self.assertEqual(1697679936056, transaction.signedDate) self.assertIsNone(transaction.revocationReason) self.assertIsNone(transaction.revocationDate) self.assertFalse(transaction.isUpgraded) self.assertEqual(OfferType.INTRODUCTORY_OFFER, transaction.offerType) + self.assertEqual(1, transaction.rawOfferType) self.assertIsNone(transaction.offerIdentifier) self.assertEqual(Environment.XCODE, transaction.environment) + self.assertEqual("Xcode", transaction.rawEnvironment) self.assertEqual("USA", transaction.storefront) self.assertEqual("143441", transaction.storefrontId) self.assertEqual(TransactionReason.PURCHASE, transaction.transactionReason) + self.assertEqual("PURCHASE", transaction.rawTransactionReason) def test_xcode_signed_renewal_info(self): verifier = get_signed_data_verifier(Environment.XCODE, XCODE_BUNDLE_ID) @@ -74,6 +80,7 @@ def test_xcode_signed_renewal_info(self): self.assertEqual("pass.premium", renewal_info.autoRenewProductId) self.assertEqual("pass.premium", renewal_info.productId) self.assertEqual(AutoRenewStatus.ON, renewal_info.autoRenewStatus) + self.assertEqual(1, renewal_info.rawAutoRenewStatus) self.assertIsNone(renewal_info.isInBillingRetryPeriod) self.assertIsNone(renewal_info.priceIncreaseStatus) self.assertIsNone(renewal_info.gracePeriodExpiresDate) @@ -81,6 +88,7 @@ def test_xcode_signed_renewal_info(self): self.assertIsNone(renewal_info.offerIdentifier) self.assertEqual(1697679936711, renewal_info.signedDate) self.assertEqual(Environment.XCODE, renewal_info.environment) + self.assertEqual("Xcode", renewal_info.rawEnvironment) self.assertEqual(1697679936049, renewal_info.recentSubscriptionStartDate) self.assertEqual(1700358336049, renewal_info.renewalDate)