apple / app-store-server-library-python

MIT License
146 stars 31 forks source link

Support mock JWS generation/verification for unit tests #23

Closed WFT closed 10 months ago

WFT commented 1 year ago

Feature request: Support creating and verifying mock JWS values, such as transactions and notifications.

I'd like the ability to create mock JWS data that will be accepted by a SignedDataVerifier which is specifically configured for testing.

This is very similar to #22, but instead of the JWS data coming from Xcode StoreKit testing I want it to be generated in my python unit tests.

I don’t think this would need to actually generate a certificate, sign the tokens, etc. But that would certainly work.

Use case

I would like to do unit testing for my server's use of the library. I want to test the following features:

  1. Processing signed transactions to deliver content
    • Technically I could do this with #22 by reproducing each case I want to test & copying out a signed JWS transaction, but this is pretty tedious.
  2. Handling App Store notifications V2
    • StoreKit testing just can't do this at all!

Example code

Here are some tests one could write, with example API:

from appstoreserverlibrary import testing as appstore

def make_transaction():
    # Ideally this would fill in dummy values for unspecified data like
    # transactionId and deviceVerificationNonce
    return appstore.mock_transaction(
        environment=Environment.PRODUCTION,
        product_id="com.example.yearly-subscription",
        bundle_id="com.example",
        expires_date=datetime.datetime.now() + datetime.timedelta(days=30),
        type=Type.AUTO_RENEWABLE_SUBSCRIPTION
    )

# Replace a normal SignedDataVerifier with a magic testing one:
replace_server_signed_data_verifier(appstore.TestingSignedDataVerifier(...))

def test_transaction_processing(client):
    "Test that the client can get the proper content when they have a signed transaction."
    response = client.post('/api/app-store/exchange-transaction-for-content', json.dumps({
        'transaction' : make_transaction()
    }))
    assert response.code == 200
    assert has_yearly_content(response.json)

def test_refund_notification(client):
    "Test that our server is properly revoking access for refunded transactions"
    tx = make_transaction()
    notif_jws = appstore.mock_notification_v2(
        type=NotificationTypeV2.REFUND,
        subtype=None,
        data=appstore.mock_notification_data(
            bundle_id="com.example",
            transaction=appstore.mock_revoked_transaction(tx),
            renewal_info=appstore.mock_renewal_info(...),
            ...
        )
    )
    response = client.post('/api/app-store/notifications-v2', notif_jws)
    assert response.code == 200

    response = client.post('/api/app-store/exchange-transaction-for-content', json.dumps({
        'transaction' : tx
    }))
    assert response.code == 403
    assert not has_yearly_content(response.json)
WFT commented 1 year ago

The basic approach I’ve used (which I would be willing to donate to this project if it would be useful) is to have a JWSMocker class which contains its own newly-generated public/private key pair. That class has two methods:

The usage is pretty verbose (see example below), but I think it creates a nice building block on which other testing facilities can be built.

mock = JWSMocker()
now = datetime.datetime.now()
transaction = mock.sign_jws(
    JWSTransactionDecodedPayload(
        originalTransactionId="123456",
        transactionId="123456",
        webOrderLineItemId="0000001",
        bundleId="com.example.app",
        productId="com.example.app.premium.annual",
        subscriptionGroupIdentifier="com.example.app.premium",
        purchaseDate=int(now.timestamp() * 1000),
        originalPurchaseDate=int(now.timestamp() * 1000),
        expiresDate=int((now + datetime.timedelta(days=365)).timestamp() * 1000),
        quantity=1,
        type=Type.AUTO_RENEWABLE_SUBSCRIPTION,
        appAccountToken=None,
        inAppOwnershipType=InAppOwnershipType.PURCHASED,
        signedDate=int(now.timestamp() * 1000),
        revocationReason=None,
        revocationDate=None,
        isUpgraded=False,
        offerType=None,
        offerIdentifier=None,
        environment=Environment.SANDBOX,
        storefront="USA",
        storefrontId="143441",
        transactionReason=TransactionReason.PURCHASE,
    )
)
print(transaction)
verifier = mock.verifier(
    environment=Environment.SANDBOX, bundle_id="com.example.app"
)
decoded = verifier.verify_and_decode_signed_transaction(transaction)
print(decoded)
pnico commented 1 year ago

^ It would be useful to me 🙏

WFT commented 10 months ago

@pnico Hey sorry I’ve been pretty busy the last few months.

I’ve put the full code (& a small example usage) I’m proposing in this gist: https://gist.github.com/WFT/3d06c2048cac4a3da9ebad6b31962928

We’ve used it internally with success.

It’s a pretty small patch, but it’s also very closely tied to the internal implementation of SignedDataVerifier. So it could easily break in a future update of this library. That’s why I think this is better off as part of this library.

alexanderjordanbaker commented 10 months ago

Hello @WFT @pnico I am working on a testing improvement that will allow verifying signed data in a unit test, similar to what you have proposed, but without requiring changes to the internals of SignedDataVerifier (it will actually form the appropriate chain, etc) and come with unit tests for the models. I hope to have a PR out in a week or two with this change. See https://github.com/apple/app-store-server-library-java/tree/main/src/test/resources for the appropriate keys. Does this meet your need or are you looking for an embedded certificate in the library without it needing to be loaded?

WFT commented 10 months ago

@alexanderjordanbaker Thanks for the update!

Just to make sure I understand the concept: the difference is your PR would require copying testCA.pem & testSigningKey.p8 into our test suite? Then just hooking up SignedDataVerifier to use testCA.pem as a root certificate and signing our own JWS with testSigningKey.p8?

That seems like it would fulfill my needs!

The rest of this comment only makes sense if I’m understanding that correctly.


Without seeing the PR it’s a little hard to give a sure yes/no, but my concern overall with that approach would be that without a slightly higher level API it’s easier to misuse.

For example — it would be easy enough for someone to configure their SignedDataVerifier to accept both the testing root CA certificate & the actual production root certificate. It would cause no errors at any point & it would make setting up unit tests slightly easier. But they would be using a well known certificate with a well known private key. That’s a pretty serious error!

My JWSMocker proposed API above makes it harder to write such broken code in two ways:

  1. It abstracts away the construction of the verifier so the path of least resistance never results in a SignedDataVerifier with both production certs & test certs being accepted. So accepting test certificates in production is always noticed because real, non-malicious data stops being verified.
  2. The actual danger of an attacker signing their own data for a misconfigured server is quite low because the certs are generated at runtime. This means they’re different every time you relaunch your server & they’re not common to every single server using this library.

That wall of text said, there are clearly upsides to the approach of not changing the internals & just configuring everything in the normal way. And these are just quibbles about the shape of the API, really. Excited to see what you’ve got! No matter what shape it takes, it'll be a large improvement over the current state.

alexanderjordanbaker commented 10 months ago

@WFT Would 100% disablement of the cert validation work or are you looking for actually validating something? I could add a new environment to the Environment enum and then allowlist only that environment, like TEST, to bypass validation while confirming that the environment of the signed data also matches TEST (to prevent the hardcoding test cert case you were describing bypassing validation on Production/Sandbox environment data)

alexanderjordanbaker commented 10 months ago

@WFT a new environment, LOCAL_TESTING, now exists which will bypass all signature checks, by setting the expected environment of SignedDataVerifier to LOCAL_TESTING, this now effectively does

Below in my unit tests is an example of creating arbitrary signed data signed by a random ES256 key

https://github.com/apple/app-store-server-library-python/blob/7c7c8ea6b5404ff43242d0408fc3f952a43951d9/tests/util.py#L18-L19

WFT commented 10 months ago

Would 100% disablement of the cert validation work or are you looking for actually validating something

Yeah, I think this is fine! Frankly I think just calling it LOCAL_TESTING resolves most of my fear about developer mistakes.

I haven't tried it out yet, but from the tests in that commit (https://github.com/apple/app-store-server-library-python/commit/96eae1a23dfe69a43de5c9188826c7fe777ddf36) it looks like exactly what I need.

Thanks @alexanderjordanbaker !

WFT commented 10 months ago

I'm going to close this without trying it because it really does look like what I was hoping for.