From 54b24cdf58e558e2b1a1bf14312a577477bf1f25 Mon Sep 17 00:00:00 2001 From: iMacTia Date: Fri, 21 Sep 2018 17:34:15 +0100 Subject: [PATCH] Converts autorization middleware tests. Moves Faraday::Utils classes (Headers and ParamsHash) into separate files. Converts Faraday::Utils::Headers tests. --- lib/faraday/utils.rb | 188 +-------------------- lib/faraday/utils/headers.rb | 131 ++++++++++++++ lib/faraday/utils/params_hash.rb | 60 +++++++ spec/faraday/request/authorization_spec.rb | 86 ++++++++++ spec/faraday/utils/headers_spec.rb | 80 +++++++++ test/authentication_middleware_test.rb | 65 ------- test/env_test.rb | 68 -------- 7 files changed, 360 insertions(+), 318 deletions(-) create mode 100644 lib/faraday/utils/headers.rb create mode 100644 lib/faraday/utils/params_hash.rb create mode 100644 spec/faraday/request/authorization_spec.rb create mode 100644 spec/faraday/utils/headers_spec.rb delete mode 100644 test/authentication_middleware_test.rb diff --git a/lib/faraday/utils.rb b/lib/faraday/utils.rb index 4ab1f3420..46f92e6a7 100644 --- a/lib/faraday/utils.rb +++ b/lib/faraday/utils.rb @@ -1,194 +1,12 @@ require 'thread' +require_relative 'utils/headers' +require_relative 'utils/params_hash' + module Faraday module Utils extend self - # Adapted from Rack::Utils::HeaderHash - class Headers < ::Hash - def self.from(value) - new(value) - end - - def self.allocate - new_self = super - new_self.initialize_names - new_self - end - - def initialize(hash = nil) - super() - @names = {} - self.update(hash || {}) - end - - def initialize_names - @names = {} - end - - # on dup/clone, we need to duplicate @names hash - def initialize_copy(other) - super - @names = other.names.dup - end - - # need to synchronize concurrent writes to the shared KeyMap - keymap_mutex = Mutex.new - - # symbol -> string mapper + cache - KeyMap = Hash.new do |map, key| - value = if key.respond_to?(:to_str) - key - else - key.to_s.split('_'). # :user_agent => %w(user agent) - each { |w| w.capitalize! }. # => %w(User Agent) - join('-') # => "User-Agent" - end - keymap_mutex.synchronize { map[key] = value } - end - KeyMap[:etag] = "ETag" - - def [](k) - k = KeyMap[k] - super(k) || super(@names[k.downcase]) - end - - def []=(k, v) - k = KeyMap[k] - k = (@names[k.downcase] ||= k) - # join multiple values with a comma - v = v.to_ary.join(', ') if v.respond_to? :to_ary - super(k, v) - end - - def fetch(k, *args, &block) - k = KeyMap[k] - key = @names.fetch(k.downcase, k) - super(key, *args, &block) - end - - def delete(k) - k = KeyMap[k] - if k = @names[k.downcase] - @names.delete k.downcase - super(k) - end - end - - def include?(k) - @names.include? k.downcase - end - - alias_method :has_key?, :include? - alias_method :member?, :include? - alias_method :key?, :include? - - def merge!(other) - other.each { |k, v| self[k] = v } - self - end - alias_method :update, :merge! - - def merge(other) - hash = dup - hash.merge! other - end - - def replace(other) - clear - @names.clear - self.update other - self - end - - def to_hash() ::Hash.new.update(self) end - - def parse(header_string) - return unless header_string && !header_string.empty? - - headers = header_string.split(/\r\n/) - - # Find the last set of response headers. - start_index = headers.rindex { |x| x.match(/^HTTP\//) } || 0 - last_response = headers.slice(start_index, headers.size) - - last_response. - tap { |a| a.shift if a.first.index('HTTP/') == 0 }. # drop the HTTP status line - map { |h| h.split(/:\s*/, 2) }.reject { |p| p[0].nil? }. # split key and value, ignore blank lines - each { |key, value| - # join multiple values with a comma - if self[key] - self[key] << ', ' << value - else - self[key] = value - end - } - end - - protected - - def names - @names - end - end - - # hash with stringified keys - class ParamsHash < Hash - def [](key) - super(convert_key(key)) - end - - def []=(key, value) - super(convert_key(key), value) - end - - def delete(key) - super(convert_key(key)) - end - - def include?(key) - super(convert_key(key)) - end - - alias_method :has_key?, :include? - alias_method :member?, :include? - alias_method :key?, :include? - - def update(params) - params.each do |key, value| - self[key] = value - end - self - end - alias_method :merge!, :update - - def merge(params) - dup.update(params) - end - - def replace(other) - clear - update(other) - end - - def merge_query(query, encoder = nil) - if query && !query.empty? - update((encoder || Utils.default_params_encoder).decode(query)) - end - self - end - - def to_query(encoder = nil) - (encoder || Utils.default_params_encoder).encode(self) - end - - private - - def convert_key(key) - key.to_s - end - end - def build_query(params) FlatParamsEncoder.encode(params) end diff --git a/lib/faraday/utils/headers.rb b/lib/faraday/utils/headers.rb new file mode 100644 index 000000000..d44d9960d --- /dev/null +++ b/lib/faraday/utils/headers.rb @@ -0,0 +1,131 @@ +module Faraday + module Utils + # Adapted from Rack::Utils::HeaderHash + class Headers < ::Hash + def self.from(value) + new(value) + end + + def self.allocate + new_self = super + new_self.initialize_names + new_self + end + + def initialize(hash = nil) + super() + @names = {} + self.update(hash || {}) + end + + def initialize_names + @names = {} + end + + # on dup/clone, we need to duplicate @names hash + def initialize_copy(other) + super + @names = other.names.dup + end + + # need to synchronize concurrent writes to the shared KeyMap + keymap_mutex = Mutex.new + + # symbol -> string mapper + cache + KeyMap = Hash.new do |map, key| + value = if key.respond_to?(:to_str) + key + else + key.to_s.split('_'). # :user_agent => %w(user agent) + each { |w| w.capitalize! }. # => %w(User Agent) + join('-') # => "User-Agent" + end + keymap_mutex.synchronize { map[key] = value } + end + KeyMap[:etag] = "ETag" + + def [](k) + k = KeyMap[k] + super(k) || super(@names[k.downcase]) + end + + def []=(k, v) + k = KeyMap[k] + k = (@names[k.downcase] ||= k) + # join multiple values with a comma + v = v.to_ary.join(', ') if v.respond_to? :to_ary + super(k, v) + end + + def fetch(k, *args, &block) + k = KeyMap[k] + key = @names.fetch(k.downcase, k) + super(key, *args, &block) + end + + def delete(k) + k = KeyMap[k] + if k = @names[k.downcase] + @names.delete k.downcase + super(k) + end + end + + def include?(k) + @names.include? k.downcase + end + + alias_method :has_key?, :include? + alias_method :member?, :include? + alias_method :key?, :include? + + def merge!(other) + other.each { |k, v| self[k] = v } + self + end + alias_method :update, :merge! + + def merge(other) + hash = dup + hash.merge! other + end + + def replace(other) + clear + @names.clear + self.update other + self + end + + def to_hash() ::Hash.new.update(self) end + + def parse(header_string) + return unless header_string && !header_string.empty? + + headers = header_string.split(/\r\n/) + + # Find the last set of response headers. + start_index = headers.rindex { |x| x.match(/^HTTP\//) } || 0 + last_response = headers.slice(start_index, headers.size) + + last_response. + tap { |a| a.shift if a.first.index('HTTP/') == 0 }. # drop the HTTP status line + map { |h| h.split(/:\s*/, 2) }.reject { |p| p[0].nil? }. # split key and value, ignore blank lines + each { |key, value| + # join multiple values with a comma + if self[key] + self[key] << ', ' << value + else + self[key] = value + end + } + end + + protected + + def names + @names + end + end + end +end diff --git a/lib/faraday/utils/params_hash.rb b/lib/faraday/utils/params_hash.rb new file mode 100644 index 000000000..cf99ddb1a --- /dev/null +++ b/lib/faraday/utils/params_hash.rb @@ -0,0 +1,60 @@ +module Faraday + module Utils + # Hash with stringified keys + class ParamsHash < Hash + def [](key) + super(convert_key(key)) + end + + def []=(key, value) + super(convert_key(key), value) + end + + def delete(key) + super(convert_key(key)) + end + + def include?(key) + super(convert_key(key)) + end + + alias_method :has_key?, :include? + alias_method :member?, :include? + alias_method :key?, :include? + + def update(params) + params.each do |key, value| + self[key] = value + end + self + end + alias_method :merge!, :update + + def merge(params) + dup.update(params) + end + + def replace(other) + clear + update(other) + end + + def merge_query(query, encoder = nil) + if query && !query.empty? + update((encoder || Utils.default_params_encoder).decode(query)) + end + self + end + + def to_query(encoder = nil) + (encoder || Utils.default_params_encoder).encode(self) + end + + private + + def convert_key(key) + key.to_s + end + end + end +end diff --git a/spec/faraday/request/authorization_spec.rb b/spec/faraday/request/authorization_spec.rb new file mode 100644 index 000000000..e802ef06b --- /dev/null +++ b/spec/faraday/request/authorization_spec.rb @@ -0,0 +1,86 @@ +RSpec.describe Faraday::Request::Authorization do + let(:conn) do + Faraday.new do |b| + b.request auth_type, *auth_config + b.adapter :test do |stub| + stub.get('/auth-echo') do |env| + [200, {}, env[:request_headers]['Authorization']] + end + end + end + end + + shared_examples 'does not interfere with existing authentication' do + context 'and request already has an authentication header' do + let(:response) { conn.get('/auth-echo', nil, authorization: 'Token token="bar"') } + + it 'does not interfere with existing authorization' do + expect(response.body).to eq('Token token="bar"') + end + end + end + + let(:response) { conn.get('/auth-echo') } + + describe 'basic_auth' do + let(:auth_type) { :basic_auth } + + context 'when passed correct params' do + let(:auth_config) { %w(aladdin opensesame) } + + it { expect(response.body).to eq('Basic YWxhZGRpbjpvcGVuc2VzYW1l') } + + include_examples 'does not interfere with existing authentication' + end + + context 'when passed very long values' do + let(:auth_config) { ['A' * 255, ''] } + + it { expect(response.body).to eq("Basic #{'QUFB' * 85}Og==") } + + include_examples 'does not interfere with existing authentication' + end + end + + describe 'token_auth' do + let(:auth_type) { :token_auth } + + context 'when passed correct params' do + let(:auth_config) { 'quux' } + + it { expect(response.body).to eq('Token token="quux"') } + + include_examples 'does not interfere with existing authentication' + end + + context 'when other values are provided' do + let(:auth_config) { ['baz', foo: 42] } + + it { expect(response.body).to match(/^Token /) } + it { expect(response.body).to match(/token="baz"/) } + it { expect(response.body).to match(/foo="42"/) } + + include_examples 'does not interfere with existing authentication' + end + end + + describe 'authorization' do + let(:auth_type) { :authorization } + + context 'when passed two strings' do + let(:auth_config) { ['custom', 'abc def'] } + + it { expect(response.body).to eq('custom abc def') } + + include_examples 'does not interfere with existing authentication' + end + + context 'when passed a string and a hash' do + let(:auth_config) { ['baz', foo: 42] } + + it { expect(response.body).to eq('baz foo="42"') } + + include_examples 'does not interfere with existing authentication' + end + end +end diff --git a/spec/faraday/utils/headers_spec.rb b/spec/faraday/utils/headers_spec.rb new file mode 100644 index 000000000..1dd5c4172 --- /dev/null +++ b/spec/faraday/utils/headers_spec.rb @@ -0,0 +1,80 @@ +RSpec.describe Faraday::Utils::Headers do + subject { Faraday::Utils::Headers.new } + + context 'when Content-Type is set to application/json' do + before { subject['Content-Type'] = 'application/json' } + + it { expect(subject.keys).to eq(['Content-Type']) } + it { expect(subject['Content-Type']).to eq('application/json') } + it { expect(subject['CONTENT-TYPE']).to eq('application/json') } + it { expect(subject['content-type']).to eq('application/json') } + it { is_expected.to include('content-type') } + end + + context 'when Content-Type is set to application/xml' do + before { subject['Content-Type'] = 'application/xml' } + + it { expect(subject.keys).to eq(['Content-Type']) } + it { expect(subject['Content-Type']).to eq('application/xml') } + it { expect(subject['CONTENT-TYPE']).to eq('application/xml') } + it { expect(subject['content-type']).to eq('application/xml') } + it { is_expected.to include('content-type') } + end + + describe '#fetch' do + before { subject['Content-Type'] = 'application/json' } + + it { expect(subject.fetch('Content-Type')).to eq('application/json') } + it { expect(subject.fetch('CONTENT-TYPE')).to eq('application/json') } + it { expect(subject.fetch(:content_type)).to eq('application/json') } + it { expect(subject.fetch('invalid', 'default')).to eq('default') } + it { expect(subject.fetch('invalid', false)).to eq(false) } + it { expect(subject.fetch('invalid', nil)).to be_nil } + it { expect(subject.fetch('Invalid') { |key| "#{key} key" }).to eq('Invalid key') } + it 'calls a block when provided' do + block_called = false + expect(subject.fetch('content-type') { block_called = true }).to eq('application/json') + expect(block_called).to be_falsey + end + it 'raises an error if key not found' do + expected_error = defined?(KeyError) ? KeyError : IndexError + expect { subject.fetch('invalid') }.to raise_error(expected_error) + end + end + + describe '#delete' do + before do + subject['Content-Type'] = 'application/json' + @deleted = subject.delete('content-type') + end + + it { expect(@deleted).to eq('application/json') } + it { expect(subject.size).to eq(0) } + it { is_expected.not_to include('content-type') } + it { expect(subject.delete('content-type')).to be_nil } + end + + describe '#parse' do + before { subject.parse(headers) } + + context 'when response headers leave http status line out' do + let(:headers) { "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n" } + + it { expect(subject.keys).to eq(%w(Content-Type)) } + it { expect(subject['Content-Type']).to eq('text/html') } + it { expect(subject['content-type']).to eq('text/html') } + end + + context 'when response headers values include a colon' do + let(:headers) { "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nLocation: http://sushi.com/\r\n\r\n" } + + it { expect(subject['location']).to eq('http://sushi.com/') } + end + + context 'when response headers include a blank line' do + let(:headers) { "HTTP/1.1 200 OK\r\n\r\nContent-Type: text/html\r\n\r\n" } + + it { expect(subject['content-type']).to eq('text/html') } + end + end +end diff --git a/test/authentication_middleware_test.rb b/test/authentication_middleware_test.rb deleted file mode 100644 index 1fbad6169..000000000 --- a/test/authentication_middleware_test.rb +++ /dev/null @@ -1,65 +0,0 @@ -require File.expand_path('../helper', __FILE__) - -class AuthenticationMiddlewareTest < Faraday::TestCase - def conn - Faraday::Connection.new('http://example.net/') do |builder| - yield(builder) - builder.adapter :test do |stub| - stub.get('/auth-echo') do |env| - [200, {}, env[:request_headers]['Authorization']] - end - end - end - end - - def test_basic_middleware_adds_basic_header - response = conn { |b| b.request :basic_auth, 'aladdin', 'opensesame' }.get('/auth-echo') - assert_equal 'Basic YWxhZGRpbjpvcGVuc2VzYW1l', response.body - end - - def test_basic_middleware_adds_basic_header_correctly_with_long_values - response = conn { |b| b.request :basic_auth, 'A' * 255, '' }.get('/auth-echo') - assert_equal "Basic #{'QUFB' * 85}Og==", response.body - end - - def test_basic_middleware_does_not_interfere_with_existing_authorization - response = conn { |b| b.request :basic_auth, 'aladdin', 'opensesame' }. - get('/auth-echo', nil, :authorization => 'Token token="bar"') - assert_equal 'Token token="bar"', response.body - end - - def test_token_middleware_adds_token_header - response = conn { |b| b.request :token_auth, 'quux' }.get('/auth-echo') - assert_equal 'Token token="quux"', response.body - end - - def test_token_middleware_includes_other_values_if_provided - response = conn { |b| - b.request :token_auth, 'baz', :foo => 42 - }.get('/auth-echo') - assert_match(/^Token /, response.body) - assert_match(/token="baz"/, response.body) - assert_match(/foo="42"/, response.body) - end - - def test_token_middleware_does_not_interfere_with_existing_authorization - response = conn { |b| b.request :token_auth, 'quux' }. - get('/auth-echo', nil, :authorization => 'Token token="bar"') - assert_equal 'Token token="bar"', response.body - end - - def test_authorization_middleware_with_string - response = conn { |b| - b.request :authorization, 'custom', 'abc def' - }.get('/auth-echo') - assert_match(/^custom abc def$/, response.body) - end - - def test_authorization_middleware_with_hash - response = conn { |b| - b.request :authorization, 'baz', :foo => 42 - }.get('/auth-echo') - assert_match(/^baz /, response.body) - assert_match(/foo="42"/, response.body) - end -end diff --git a/test/env_test.rb b/test/env_test.rb index fd1cb2242..30f057b04 100644 --- a/test/env_test.rb +++ b/test/env_test.rb @@ -97,75 +97,7 @@ def make_env(method = :get, connection = @conn, &block) end end -class HeadersTest < Faraday::TestCase - def setup - @headers = Faraday::Utils::Headers.new - end - - def test_normalizes_different_capitalizations - @headers['Content-Type'] = 'application/json' - assert_equal ['Content-Type'], @headers.keys - assert_equal 'application/json', @headers['Content-Type'] - assert_equal 'application/json', @headers['CONTENT-TYPE'] - assert_equal 'application/json', @headers['content-type'] - assert @headers.include?('content-type') - - @headers['content-type'] = 'application/xml' - assert_equal ['Content-Type'], @headers.keys - assert_equal 'application/xml', @headers['Content-Type'] - assert_equal 'application/xml', @headers['CONTENT-TYPE'] - assert_equal 'application/xml', @headers['content-type'] - end - - def test_fetch_key - @headers['Content-Type'] = 'application/json' - block_called = false - assert_equal 'application/json', @headers.fetch('content-type') { block_called = true } - assert_equal 'application/json', @headers.fetch('Content-Type') - assert_equal 'application/json', @headers.fetch('CONTENT-TYPE') - assert_equal 'application/json', @headers.fetch(:content_type) - assert_equal false, block_called - assert_equal 'default', @headers.fetch('invalid', 'default') - assert_equal false, @headers.fetch('invalid', false) - assert_nil @headers.fetch('invalid', nil) - - assert_equal 'Invalid key', @headers.fetch('Invalid') { |key| "#{key} key" } - - expected_error = defined?(KeyError) ? KeyError : IndexError - assert_raises(expected_error) { @headers.fetch('invalid') } - end - - def test_delete_key - @headers['Content-Type'] = 'application/json' - assert_equal 1, @headers.size - assert @headers.include?('content-type') - assert_equal 'application/json', @headers.delete('content-type') - assert_equal 0, @headers.size - assert !@headers.include?('content-type') - assert_nil @headers.delete('content-type') - end - - def test_parse_response_headers_leaves_http_status_line_out - @headers.parse("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n") - assert_equal %w(Content-Type), @headers.keys - end - - def test_parse_response_headers_parses_lower_cased_header_name_and_value - @headers.parse("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n") - assert_equal 'text/html', @headers['content-type'] - end - - def test_parse_response_headers_parses_lower_cased_header_name_and_value_with_colon - @headers.parse("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nLocation: http://sushi.com/\r\n\r\n") - assert_equal 'http://sushi.com/', @headers['location'] - end - - def test_parse_response_headers_parses_blank_lines - @headers.parse("HTTP/1.1 200 OK\r\n\r\nContent-Type: text/html\r\n\r\n") - assert_equal 'text/html', @headers['content-type'] - end -end class ResponseTest < Faraday::TestCase def setup