🚀 See the 2024 Ruby on Rails Community Survey results!
Article  |  Development

Testing Chargify Direct with Capybara WebKit

Reading time: ~ 4 minutes

We've been working with Pressable to add features to their application and to make their test suite more robust.

Most of the specs that we added or changed included integrating WebMock. This works well in the majority of the of the specs but there was one test that was continually failing for us in spite of us mocking it.

The test - an integration/acceptance test - was ensuring that the sign up process worked with Chargify's Direct process.

This process is referred to as a Transparent Redirect, where your application posts directly to a the payment processor and then the payment processor redirects back to your application to verify cryptographically signed data.

This has several benefits for the application stakeholders. First, it offloads PCI Compliance to the payment processor because the application isn't storing credit card data. Second, the user experience is seamless because the user isn't shunted to another unbranded page to verify data.

Clearly the benefits of using this solution are evident but this presents a problem for integration tests for a number of reasons:

  1. The rack-test driver that's used by default in Capybara cannot post to outside URLs.
  2. Enabling the test to use a headless browser like Capybara-Webkit means that we can no longer use WebMock to intercept HTTP requests because that browser will start in a different thread.

The approach that I used was to start up a Rack server to mock/fake the responses that would come from Chargify's V2 API.

I first created a simple Rack server that I placed in the spec/support directory.

require 'rack'

module MockChargifyServer
    class Server
        def initialize(capybara_host, capybara_port)
            @app = Rack::Builder.new do
                use Rack::CommonLogger
                use Rack::ShowExceptions
            end
        end

        def call(env)
            @app.call(env)
        end
    end
end

I'm passing in the Capybara host and port here so that I can send a redirect back to my Rails application.

Looking at the Chargify API documentation, it looks like we're going to need to fake a signups path and a calls path. So I created some simple routes in my Rack server:

require 'rack'

module MockChargifyServer
    class Server
        def initialize(capybara_host, capybara_port)
            @app = Rack::Builder.new do
                use Rack::CommonLogger
                use Rack::ShowExceptions

                map '/signups' do
                end

                map '/calls' do
                end
            end
        end

        def call(env)
            @app.call(env)
        end
    end
end

The routes don't do anything yet, so we'll need to wire them up. Let's tackle the signups route first.

This is what our Rails application should be posting to and it should be redirecting back to our app with some query parameters.

I created a class to handle this:

class SignupResponse
    def initialize(capybara_host, capybara_port)
        @capybara_host = capybara_host
        @capybara_port = capybara_port
    end

    def call(env)
        response = Rack::Response.new
        parameters = ParameterBuilder.new.create

        response.redirect("http://#{@capybara_host}:#{@capybara_port.to_s}/subscription/verify?#{parameters}")
        response.finish
    end
end

As you can see in the call method we are creating a rack response and setting it as a redirect. The parameters that we send over are built in a ParameterBuilder class:

class ParameterBuilder
    def initialize
        @parameter_list = {
            api_id: ENV['CHARGIFY_DIRECT_API_ID'],
            timestamp: Time.zone.now,
            nonce: SecureRandom.hex(20),
            status_code: 200,
            result_code: 2000,
            call_id: 'chargify_id'
        }

        @parameters = []
    end

    def create
        @parameter_list.each do |k,v|
            @parameters << Parameter.new(k,v)
        end

        @parameters << Parameter.new('signature', sign_parameters)
        @parameters.map(&:to_s).join("&")
    end

    private
    def sign_parameters
        key = ENV['CHARGIFY_DIRECT_API_SECRET']
        data = @parameters.map(&:value).join('')
        OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), key, data)
    end
end

class Parameter
    attr_reader :value

    def initialize(name, value)
        @name = name
        @value = value
    end

    def to_s
        "#{@name}=#{@value}"
    end
end

The code here is pretty straightforward but there are a few things of note. The parameter list has to be in this order otherwise the signature won't match (this is enforced by the gem that we are using). Additionally, we are creating a signature that is a list of all the parameters signed with SHA1.

When our rack app redirects back to our Rails app, we have to verify the parameters to ensure that they haven't been tampered with during transit. Again, I create another class to handle this for me:

class CallResponse
    def call(env)
        json = File.open(File.dirname(__FILE__) + '/../fixtures/chargify_v2_subscription_call_response.json').read
        response = Rack::Response.new json, 200, {"Content-Type"=>"application/json"}
        response.finish
    end
end

Here we are using a fixture file to return specific account data and returning it back as json.

With our classes built we can plug them into our rack app:

require 'rack'

module MockChargifyServer
    class Server
        def initialize(capybara_host, capybara_port)
            @app = Rack::Builder.new do
                use Rack::CommonLogger
                use Rack::ShowExceptions

                map '/signups' do
                    run SignupResponse.new(capybara_host, capybara_post)
                end

                map '/calls' do
                    run CallResponse.new
                end
            end
        end

        def call(env)
            @app.call(env)
        end
    end
end

One thing to note, when our application makes a request to verify the parameters, it will make a request to calls/:call_id. Our route /calls catches that request as well, we just don't really care what the call id is.

So now that we have a Rack app that can stand in for Chargify we have to plug this in to our Rails app and our spec.

When our view page loads, we have a before filter that gets triggered to setup a Chargify client object. We're using the chargify2 gem for this.

We also setup a helper method (simply named chargify_client) in our controller so that we can override that later.

In our spec we find out what our Capybara server's host and port are and then startup a new rack server in a different thread:

context 'Subscription Creation' do
    before do
        host, port = [Capybara.current_session.server.host, Capybara.current_session.server.port]
        @server_thread = Thread.new do
            Rack::Handler::Thin.run MockChargifyServer::Server.new(host, port), Port: 9292
        end

        SubscriptionsController.any_instance.stub(:chargify_client).and_return(stub_chargify_client)
        signup_user
    end

    after do
        @server_thread.exit
    end

    it 'does our expectation here', js: true

Above you can see that we are starting up Thin to run our Rack application on port 9292. Additionally, we're also stubbing out the charfigy_client and replacing it with our stub:

def stub_chargify_client
    Chargify2::Client.new({
        :api_id       => ENV['CHARGIFY_DIRECT_API_ID'],
        :api_password => ENV['CHARGIFY_DIRECT_API_PASSWORD'],
        :api_secret   => ENV['CHARGIFY_DIRECT_API_SECRET'],
        :base_uri => 'http://localhost:9292'
    })
end

Now when our test runs, it happily thinks that Chargify is at http://localhost:9292 and will send requests to our Rack application.

Here is the full code for the MockChargifyServer.

Final Thoughts

Spinning up a Rack server to intercept calls to Chargify was the solution that worked for us. If you've had to deal with testing transparent redirects in your app, what what the approach you've used? We'd love to hear about it.

P.S. Get in touch if you need any assistance integrating your Ruby on Rails application with Chargify.

Have a project that needs help?