mgomes / api_auth

HMAC authentication for Rails and HTTP Clients
MIT License
480 stars 147 forks source link

Rspec. How to sign request in request specs? #109

Closed evgeniy-trebin closed 6 years ago

evgeniy-trebin commented 8 years ago

I have a spec type: :request and I want to add authentication via ApiAuth. How can i do that? I know that I can use it in specs for controllers as follows

request.env['HTTP_ACCEPT'] = 'application/json'
user = defined?(current_user) ? current_user : Factory.create(:admin_user)
ApiAuth.sign!(request, user.id, user.api_secret_key)

But request object is missing in specs for requests

describe 'Cities API', type: :request do
  let(:cities) { City.all }
  let(:admin_user) { Factory.create(:admin_user) }

  context 'given an unauthorized request' do
    it 'returns 401 status' do
      get '/api/cities'

      expect(response).to have_http_status(:unauthorized)
    end
  end

  context 'given an authorized request' do
    it 'sends a list of cities' do
      # i need authentication here
      get '/api/cities'

      expect(response).to be_success
    end
  end
end

Now I stub authentication

describe 'Cities API', type: :request do
  let(:cities) { City.all }
  let(:admin_user) { Factory.create(:admin_user) }

  before { Factory.create(:route) }

  context 'given an unauthorized request' do
    it 'returns 401 status' do
      get '/api/cities'

      expect(response).to have_http_status(:unauthorized)
    end
  end

  context 'given an authorized request' do
    before(:each) do
      allow_any_instance_of(CitiesController).to receive(:authenticate_user!).and_return(true)
      allow_any_instance_of(CitiesController).to receive(:admin_user).and_return(admin_user)
    end

    it 'sends a list of cities' do
      get '/api/cities'

      expect(response).to be_success
    end
  end
end

But I want to avoid of using stubs in request specs. Does anybody have any ideas?

uday-rayala commented 8 years ago

I am also having trouble writing request specs for apis protected by api_auth. Any suggestions on how to do it would help.

evgeniy-trebin commented 8 years ago

I resolved it next way

# lib/query_params.rb
module QueryParams

  def self.encode(value, key = nil)
    case value
    when Hash then
      value.map {|k, v| encode(v, append_key(key, k)) }.join('&')
    when Array then
      value.map {|v| encode(v, "#{key}[]") }.join('&')
    when nil then
      key.to_s
    else
      "#{key}=#{CGI.escape(value.to_s)}"
    end
  end

  def self.append_key(root_key, key)
    root_key.nil? ? key : "#{root_key}[#{key}]"
  end

end
#spec/support/request_helpers.rb
require Rails.root.join('lib/query_params')

module RequestHelpers

  extend ActiveSupport::Concern

  included do
    def auth_headers(verb, path, params = {})
      query_string = ''
      full_path = path
      if verb.to_s == 'get' && params.any?
        query_string = QueryParams.encode(params)
        full_path =  [path, query_string].compact.join('?')
      end
      url = "http://#{host}#{full_path}"
      uri = URI.parse(url)
      request = "Net::HTTP::#{verb.capitalize}".constantize.new(uri.request_uri)
      env = {
        'REQUEST_METHOD' => verb.to_s.upcase,
        'SERVER_NAME' => 'www.example.com',
        'SERVER_PORT' => 80,
        'QUERY_STRING' => query_string,
        'PATH_INFO' => path,
        'HTTPS' => 'off',
        'SCRIPT_NAME' => '',
        'REMOTE_ADDR' => '127.0.0.1',
        'REQUEST_URI' => path,
        'HTTP_HOST' => 'www.example.com',
        'HTTP_COOKIE' => '',
        'ORIGINAL_FULLPATH' => full_path,
        'ORIGINAL_SCRIPT_NAME' => ''
      }
      request.body = QueryParams.encode(params) if params.any?
      request.add_field('CONTENT_TYPE', 'application/x-www-form-urlencoded')
      signed_request = ApiAuth.sign!(request, admin_user.id, admin_user.api_secret_key)
      headers = {}
      signed_request.each_header {|k, v| headers[k] = v }
      env.merge(headers)
    end
  end
end
#rails_helper.rb
RSpec.configure do |config|
  config.include RequestHelpers, type: :request
end
describe 'Cities API', type: :request do
  let(:cities) { City.all }
  let(:admin_user) { Factory.create(:admin_user) }
  let(:path) { '/api/cities' }

  before { Factory.create(:route) }

  let(:request_block) do
    proc {|headers| get path, {}, headers }
  end

  context 'given an unauthorized request' do
    it 'returns 401 status' do
      request_block.call
      expect(response).to have_http_status(:unauthorized)
    end
  end

  context 'given an authorized request' do
    it 'sends a list of cities' do
      request_block.call(auth_headers(:get, path))
      expect(response).to be_success
    end
  end
end
Adam-Stomski commented 8 years ago

Hey, there is one that I'm using, working with rails (5.0.0.rc1), rspec (3.1.0):

# rspec/support/helper_methods.rb
#
# sign request with user
#
def sign_request(user, path, type = :get)
  sign_request_raw(user.id, user.auth_token, path, type)
end

#
# sign request
#
def sign_request_raw(id, auth_token, path, type = :get)
  allow_any_instance_of(ActionDispatch::TestRequest).to receive(:fullpath).and_return(path)
  request.env["HTTP_ACCEPT"] = "application/json"

  if type != :get
    request.env['CONTENT_TYPE'] = "application/x-www-form-urlencoded"
  end

  ApiAuth.sign!(request, id, auth_token)
end
describe "POST create" do
  before(:each) do
    @user = FactoryGirl.create(:user)
    @friend = FactoryGirl.create(:user)
  end

  context "with valid data" do
    before(:each) do
      sign_request(@user, api_v1_friend_requests_path, :post)
      post :create, params: { friend_request: { id: @friend.id } }
    end

    it "has current_user" do
      expect(assigns(:current_user)).to eq(@user)
    end
  end

  context "with invalid data" do
    it "returns status 401 not authorized" do
      post :create, params: { friend_request: { id: @friend.id } }
      expect(response.status).to eq(401)
    end
  end
end
DaKaZ commented 7 years ago

@Adam-Stomski I love your example, but my request tests do not have access to the request object like yours. Can you post your spec_helper or show me what to include to get access to that on a Rails 5.1 project? I had been using Rack::Test::Methods but this doesn't give us access to the request object so we can't use ApiAuth.sign! - or is there a better way to sign a request when using Rack::Test::Methods?

DaKaZ commented 6 years ago

I never followed up on this, but here is what I ended up doing if anyone is interested.

First, I created a helper:

module Requests
  module ApiHelpers
    include Rack::Test::Methods

    def json
      JSON.parse(last_response.body).deep_symbolize_keys
    end

    def result
      json[:result]
    end

    def api_request(api_account, path, type = :get, data = nil)
      sign_request_raw(api_account.id, api_account.secret_key, path, type, data)
      case type
      when :get
        get path
      when :put
        put path, data
      when :post
        post path, data
      when :delete
        delete path
      else
        raise 'Unknown method'
      end
      expect(json.has_key?(:messages)).to be_truthy
      expect(json[:messages].keys).to match_array [:info, :warn, :error]
      if last_response.status >= 200 && last_response.status < 300
        expect(json.has_key?(:result)).to be_truthy
      end
      last_response
    end

    private

    def sign_request_raw(id, secret_key, path, type, data)
      allow_any_instance_of(ActionDispatch::TestRequest).to receive(:fullpath).and_return(path)

      timestamp = Time.now.utc.httpdate
      content_type = "application/json"
      content_md5 = ''

      header "ACCEPT", content_type
      header "CONTENT_TYPE", content_type
      header "DATE", timestamp
      unless data.nil?
        content_md5 = Digest::MD5.hexdigest(data)
        header "CONTENT_MD5", content_md5
      end

      # handle uri params for get requests`
      if path.include?('?')
        uri = path.split('?')
        params = uri[1].split('&')
        encoded_params = ""
        params.each do |param|
          next unless param.include?('=')
          encoded_params += '&' if encoded_params.length.positive?
          split_param = param.split('=')
          encoded_params += split_param[0] + '=' + CGI.escape(split_param[1])
        end
        path = uri[0] + '?' + encoded_params
      end

      c_string = canonical_string(type, content_type, content_md5, path, timestamp)
      puts "EmployeeProfile canonical_string:'#{c_string}'"
      digest = OpenSSL::Digest.new('sha1')

      sig = Base64.strict_encode64(OpenSSL::HMAC.digest(digest, secret_key, c_string))
      header "AUTHORIZATION", "APIAuth #{id}:#{sig}"
    end

    def canonical_string(request_method, content_type, content_md5, path, timestamp)
      [request_method.upcase,
       content_type,
       content_md5,
       path,
       timestamp].join(',')
    end
  end
end

Next, I included that helper on all request specs. To do this, add this line to your spec_helper.rb's RSpec.configure block:

# in spec_helper.rb
RSpec.configure do |config|
  # your other config is here
  config.include Requests::ApiHelpers, type: :request
end

This issue can probably be closed

notunderground commented 6 years ago

One distinction that took me awhile to recognize was @Adam-Stomski's example works with type: :controller @evgeniy-trebin's example works with type: :request

mgomes commented 6 years ago

Thanks for the post and clarifications every :+1: