castle-rb 3.6.2 → 4.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -5
  3. data/lib/castle.rb +3 -1
  4. data/lib/castle/client.rb +20 -15
  5. data/lib/castle/configuration.rb +30 -3
  6. data/lib/castle/context/default.rb +36 -18
  7. data/lib/castle/context/sanitizer.rb +1 -0
  8. data/lib/castle/events.rb +49 -0
  9. data/lib/castle/extractors/client_id.rb +7 -3
  10. data/lib/castle/extractors/headers.rb +24 -30
  11. data/lib/castle/extractors/ip.rb +49 -5
  12. data/lib/castle/header_filter.rb +35 -0
  13. data/lib/castle/header_formatter.rb +3 -0
  14. data/lib/castle/validators/not_supported.rb +1 -0
  15. data/lib/castle/validators/present.rb +1 -0
  16. data/lib/castle/version.rb +1 -1
  17. data/spec/integration/rails/rails_spec.rb +61 -0
  18. data/spec/integration/rails/support/all.rb +6 -0
  19. data/spec/integration/rails/support/application.rb +15 -0
  20. data/spec/integration/rails/support/home_controller.rb +21 -0
  21. data/spec/lib/castle/api/request/build_spec.rb +4 -2
  22. data/spec/lib/castle/api/request_spec.rb +1 -0
  23. data/spec/lib/castle/client_spec.rb +5 -3
  24. data/spec/lib/castle/commands/authenticate_spec.rb +1 -0
  25. data/spec/lib/castle/commands/identify_spec.rb +1 -0
  26. data/spec/lib/castle/commands/impersonate_spec.rb +1 -0
  27. data/spec/lib/castle/commands/track_spec.rb +1 -0
  28. data/spec/lib/castle/configuration_spec.rb +18 -2
  29. data/spec/lib/castle/context/default_spec.rb +10 -11
  30. data/spec/lib/castle/events_spec.rb +5 -0
  31. data/spec/lib/castle/extractors/client_id_spec.rb +2 -1
  32. data/spec/lib/castle/extractors/headers_spec.rb +66 -49
  33. data/spec/lib/castle/extractors/ip_spec.rb +56 -12
  34. data/spec/lib/castle/header_filter_spec.rb +38 -0
  35. data/spec/lib/castle/header_formatter_spec.rb +1 -1
  36. data/spec/lib/castle/utils/cloner_spec.rb +1 -0
  37. data/spec/lib/castle/utils/timestamp_spec.rb +3 -4
  38. data/spec/lib/castle/utils_spec.rb +1 -1
  39. data/spec/lib/castle/validators/not_supported_spec.rb +1 -3
  40. metadata +31 -3
@@ -4,14 +4,58 @@ module Castle
4
4
  module Extractors
5
5
  # used for extraction of ip from the request
6
6
  class IP
7
- def initialize(request)
8
- @request = request
7
+ # ordered list of ip headers for ip extraction
8
+ DEFAULT = %w[X-Forwarded-For Client-Ip Remote-Addr].freeze
9
+ # default header fallback when ip is not found
10
+ FALLBACK = 'Remote-Addr'
11
+
12
+ private_constant :FALLBACK, :DEFAULT
13
+
14
+ # @param headers [Hash]
15
+ def initialize(headers)
16
+ @headers = headers
17
+ @ip_headers = Castle.config.ip_headers + DEFAULT
18
+ @proxies = Castle.config.trusted_proxies + Castle::Configuration::TRUSTED_PROXIES
9
19
  end
10
20
 
21
+ # Order of headers:
22
+ # .... list of headers defined by ip_headers
23
+ # X-Forwarded-For
24
+ # Client-Ip is
25
+ # Remote-Addr
26
+ # @return [String]
11
27
  def call
12
- return @request.env['HTTP_CF_CONNECTING_IP'] if @request.env['HTTP_CF_CONNECTING_IP']
13
- return @request.remote_ip if @request.respond_to?(:remote_ip)
14
- @request.ip
28
+ @ip_headers.each do |ip_header|
29
+ ip_value = calculate_ip(ip_header)
30
+ return ip_value if ip_value
31
+ end
32
+
33
+ @headers[FALLBACK]
34
+ end
35
+
36
+ private
37
+
38
+ # @param header [String]
39
+ # @return [String]
40
+ def calculate_ip(header)
41
+ ips = ips_from(header)
42
+ remove_proxies(ips).first
43
+ end
44
+
45
+ # @param ips [Array<String>]
46
+ # @return [Array<String>]
47
+ def remove_proxies(ips)
48
+ ips.reject { |ip| @proxies.any? { |proxy| proxy.match(ip) } }
49
+ end
50
+
51
+ # @param header [String]
52
+ # @return [Array<String>]
53
+ def ips_from(header)
54
+ value = @headers[header]
55
+
56
+ return [] unless value
57
+
58
+ value.strip.split(/[,\s]+/).reverse
15
59
  end
16
60
  end
17
61
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castle
4
+ # used for preparing valuable headers list
5
+ class HeaderFilter
6
+ # headers filter
7
+ # HTTP_ - this is how Rack prefixes incoming HTTP headers
8
+ # CONTENT_LENGTH - for responses without Content-Length or Transfer-Encoding header
9
+ # REMOTE_ADDR - ip address header returned by web server
10
+ VALUABLE_HEADERS = /^(
11
+ HTTP_.*|
12
+ CONTENT_LENGTH|
13
+ REMOTE_ADDR
14
+ )$/x.freeze
15
+
16
+ private_constant :VALUABLE_HEADERS
17
+
18
+ # @param request [Rack::Request]
19
+ def initialize(request)
20
+ @request_env = request.env
21
+ @formatter = HeaderFormatter.new
22
+ end
23
+
24
+ # Serialize HTTP headers
25
+ # @return [Hash]
26
+ def call
27
+ @request_env.keys.each_with_object({}) do |header_name, acc|
28
+ next unless header_name.match(VALUABLE_HEADERS)
29
+
30
+ formatted_name = @formatter.call(header_name)
31
+ acc[formatted_name] = @request_env[header_name]
32
+ end
33
+ end
34
+ end
35
+ end
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Castle
4
+ # formats header name
4
5
  class HeaderFormatter
6
+ # @param header [String]
7
+ # @return [String]
5
8
  def call(header)
6
9
  header.to_s.gsub(/^HTTP(?:_|-)/i, '').split(/_|-/).map(&:capitalize).join('-')
7
10
  end
@@ -7,6 +7,7 @@ module Castle
7
7
  def call(options, keys)
8
8
  keys.each do |key|
9
9
  next unless options.key?(key)
10
+
10
11
  raise Castle::InvalidParametersError, "#{key} is/are not supported"
11
12
  end
12
13
  end
@@ -7,6 +7,7 @@ module Castle
7
7
  def call(options, keys)
8
8
  keys.each do |key|
9
9
  next unless options[key].to_s.empty?
10
+
10
11
  raise Castle::InvalidParametersError, "#{key} is missing or empty"
11
12
  end
12
13
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Castle
4
- VERSION = '3.6.2'
4
+ VERSION = '4.0.0'
5
5
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative 'support/all'
5
+
6
+ RSpec.describe HomeController, type: :request do
7
+ describe '#index' do
8
+ let(:request) do
9
+ {
10
+ 'event' => '$login.succeeded',
11
+ 'user_id' => '123',
12
+ 'properties' => { 'key' => 'value' },
13
+ 'user_traits' => { 'key' => 'value' },
14
+ 'timestamp' => now.utc.iso8601(3),
15
+ 'sent_at' => now.utc.iso8601(3),
16
+ 'context' => {
17
+ 'client_id' => '',
18
+ 'active' => true,
19
+ 'origin' => 'web',
20
+ 'headers' => {
21
+ 'Accept' => 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5',
22
+ 'Authorization' => true,
23
+ 'Content-Length' => '0',
24
+ 'Cookie' => true,
25
+ 'Host' => 'www.example.com',
26
+ 'X-Forwarded-For' => '5.5.5.5, 1.2.3.4',
27
+ 'Remote-Addr' => '127.0.0.1'
28
+ },
29
+ 'ip' => '1.2.3.4',
30
+ 'library' => {
31
+ 'name' => 'castle-rb',
32
+ 'version' => Castle::VERSION
33
+ }
34
+ }
35
+ }
36
+ end
37
+ let(:now) { Time.now }
38
+ let(:headers) do
39
+ {
40
+ 'HTTP_AUTHORIZATION' => 'Basic 123',
41
+ 'HTTP_X_FORWARDED_FOR' => '5.5.5.5, 1.2.3.4'
42
+ }
43
+ end
44
+
45
+ before do
46
+ Timecop.freeze(now)
47
+ stub_request(:post, 'https://api.castle.io/v1/track')
48
+ get '/', headers: headers
49
+ end
50
+
51
+ after { Timecop.return }
52
+
53
+ it do
54
+ assert_requested :post, 'https://api.castle.io/v1/track', times: 1 do |req|
55
+ JSON.parse(req.body) == request
56
+ end
57
+ end
58
+
59
+ it { expect(response).to be_successful }
60
+ end
61
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV['RAILS_ENV'] = 'test'
4
+ require_relative 'application'
5
+ require_relative 'home_controller'
6
+ require 'rspec/rails'
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_controller/railtie'
4
+
5
+ class TestApp < Rails::Application
6
+ secrets.secret_token = 'secret_token'
7
+ secrets.secret_key_base = 'secret_key_base'
8
+
9
+ config.logger = Logger.new($stdout)
10
+ Rails.logger = config.logger
11
+
12
+ routes.draw do
13
+ get '/' => 'home#index'
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HomeController < ActionController::Base
4
+ def index
5
+ request_context = ::Castle::Client.to_context(request)
6
+ track_options = ::Castle::Client.to_options(
7
+ event: '$login.succeeded',
8
+ user_id: '123',
9
+ properties: {
10
+ key: 'value'
11
+ },
12
+ user_traits: {
13
+ key: 'value'
14
+ }
15
+ )
16
+ client = ::Castle::Client.new(request_context)
17
+ client.track(track_options)
18
+
19
+ render inline: 'hello'
20
+ end
21
+ end
@@ -21,11 +21,13 @@ describe Castle::API::Request::Build do
21
21
 
22
22
  context 'when post' do
23
23
  let(:time) { Time.now.utc.iso8601(3) }
24
- let(:command) { Castle::Commands::Track.new({}).build(event: '$login.succeeded', name: "\xC4") }
24
+ let(:command) do
25
+ Castle::Commands::Track.new({}).build(event: '$login.succeeded', name: "\xC4")
26
+ end
25
27
  let(:expected_body) do
26
28
  {
27
29
  event: '$login.succeeded',
28
- name: "",
30
+ name: '',
29
31
  context: {},
30
32
  sent_at: time
31
33
  }
@@ -24,6 +24,7 @@ describe Castle::API::Request do
24
24
  expect(Castle::API::Request::Build).to have_received(:call)
25
25
  .with(command, expected_headers, api_secret)
26
26
  end
27
+
27
28
  it { expect(http).to have_received(:request).with(request_build) }
28
29
  end
29
30
 
@@ -244,11 +244,13 @@ describe Castle::Client do
244
244
  it { expect(request_response[:action]).to be_eql('allow') }
245
245
  it { expect(request_response[:user_id]).to be_eql('1234') }
246
246
  it { expect(request_response[:failover]).to be true }
247
- it { expect(request_response[:failover_reason]).to be_eql('Castle set to do not track.') }
247
+ it { expect(request_response[:failover_reason]).to be_eql('Castle is set to do not track.') }
248
248
  end
249
249
 
250
250
  context 'when request with fail' do
251
- before { allow(Castle::API).to receive(:request).and_raise(Castle::RequestError.new(Timeout::Error)) }
251
+ before do
252
+ allow(Castle::API).to receive(:request).and_raise(Castle::RequestError.new(Timeout::Error))
253
+ end
252
254
 
253
255
  context 'with request error and throw strategy' do
254
256
  before { allow(Castle.config).to receive(:failover_strategy).and_return(:throw) }
@@ -274,7 +276,7 @@ describe Castle::Client do
274
276
  it { expect { request_response }.to raise_error(Castle::InternalServerError) }
275
277
  end
276
278
 
277
- context 'not throw on eg deny strategy' do
279
+ describe 'not throw on eg deny strategy' do
278
280
  it { assert_not_requested :post, 'https://:secret@api.castle.io/v1/authenticate' }
279
281
  it { expect(request_response[:action]).to be_eql('allow') }
280
282
  it { expect(request_response[:user_id]).to be_eql('1234') }
@@ -10,6 +10,7 @@ describe Castle::Commands::Authenticate do
10
10
  let(:time_auto) { time_now.utc.iso8601(3) }
11
11
 
12
12
  before { Timecop.freeze(time_now) }
13
+
13
14
  after { Timecop.return }
14
15
 
15
16
  describe '.build' do
@@ -10,6 +10,7 @@ describe Castle::Commands::Identify do
10
10
  let(:time_auto) { time_now.utc.iso8601(3) }
11
11
 
12
12
  before { Timecop.freeze(time_now) }
13
+
13
14
  after { Timecop.return }
14
15
 
15
16
  describe '.build' do
@@ -11,6 +11,7 @@ describe Castle::Commands::Impersonate do
11
11
  let(:time_auto) { time_now.utc.iso8601(3) }
12
12
 
13
13
  before { Timecop.freeze(time_now) }
14
+
14
15
  after { Timecop.return }
15
16
 
16
17
  describe '.build' do
@@ -10,6 +10,7 @@ describe Castle::Commands::Track do
10
10
  let(:time_auto) { time_now.utc.iso8601(3) }
11
11
 
12
12
  before { Timecop.freeze(time_now) }
13
+
13
14
  after { Timecop.return }
14
15
 
15
16
  describe '#build' do
@@ -31,14 +31,25 @@ describe Castle::Configuration do
31
31
 
32
32
  describe 'api_secret' do
33
33
  context 'with env' do
34
+ let(:secret_key_env) { 'secret_key_env' }
35
+ let(:secret_key) { 'secret_key' }
36
+
34
37
  before do
35
38
  allow(ENV).to receive(:fetch).with(
36
39
  'CASTLE_API_SECRET', ''
37
- ).and_return('secret_key')
40
+ ).and_return(secret_key_env)
38
41
  end
39
42
 
40
43
  it do
41
- expect(config.api_secret).to be_eql('secret_key')
44
+ expect(config.api_secret).to be_eql(secret_key_env)
45
+ end
46
+
47
+ context 'when key is overwritten' do
48
+ before { config.api_secret = secret_key }
49
+
50
+ it do
51
+ expect(config.api_secret).to be_eql(secret_key)
52
+ end
42
53
  end
43
54
  end
44
55
 
@@ -48,6 +59,7 @@ describe Castle::Configuration do
48
59
  before do
49
60
  config.api_secret = value
50
61
  end
62
+
51
63
  it do
52
64
  expect(config.api_secret).to be_eql(value)
53
65
  end
@@ -69,6 +81,7 @@ describe Castle::Configuration do
69
81
  before do
70
82
  config.request_timeout = value
71
83
  end
84
+
72
85
  it do
73
86
  expect(config.request_timeout).to be_eql(value)
74
87
  end
@@ -84,6 +97,7 @@ describe Castle::Configuration do
84
97
  before do
85
98
  config.whitelisted = ['header']
86
99
  end
100
+
87
101
  it do
88
102
  expect(config.whitelisted).to be_eql(['Header'])
89
103
  end
@@ -99,6 +113,7 @@ describe Castle::Configuration do
99
113
  before do
100
114
  config.blacklisted = ['header']
101
115
  end
116
+
102
117
  it do
103
118
  expect(config.blacklisted).to be_eql(['Header'])
104
119
  end
@@ -114,6 +129,7 @@ describe Castle::Configuration do
114
129
  before do
115
130
  config.failover_strategy = :deny
116
131
  end
132
+
117
133
  it do
118
134
  expect(config.failover_strategy).to be_eql(:deny)
119
135
  end
@@ -16,24 +16,23 @@ describe Castle::Context::Default do
16
16
  let(:request) { Rack::Request.new(env) }
17
17
  let(:default_context) { subject.call }
18
18
  let(:version) { '2.2.0' }
19
-
20
- before do
21
- stub_const('Castle::VERSION', version)
22
- end
23
-
24
- it { expect(default_context[:active]).to be_eql(true) }
25
- it { expect(default_context[:origin]).to be_eql('web') }
26
-
27
- it do
28
- expect(default_context[:headers]).to be_eql(
19
+ let(:result_headers) do
20
+ {
29
21
  'X-Forwarded-For' => '1.2.3.4',
30
22
  'Accept-Language' => 'en',
31
23
  'User-Agent' => 'test',
32
24
  'Content-Length' => '0',
33
25
  'Cookie' => true
34
- )
26
+ }
35
27
  end
36
28
 
29
+ before do
30
+ stub_const('Castle::VERSION', version)
31
+ end
32
+
33
+ it { expect(default_context[:active]).to be_eql(true) }
34
+ it { expect(default_context[:origin]).to be_eql('web') }
35
+ it { expect(default_context[:headers]).to be_eql(result_headers) }
37
36
  it { expect(default_context[:ip]).to be_eql(ip) }
38
37
  it { expect(default_context[:library][:name]).to be_eql('castle-rb') }
39
38
  it { expect(default_context[:library][:version]).to be_eql(version) }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe Castle::Events do
4
+ it { expect(described_class::LOGIN_SUCCEEDED).to eq('$login.succeeded') }
5
+ end
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  describe Castle::Extractors::ClientId do
4
- subject(:extractor) { described_class.new(request, cookies) }
4
+ subject(:extractor) { described_class.new(formatted_headers, cookies) }
5
5
 
6
+ let(:formatted_headers) { Castle::HeaderFilter.new(request).call }
6
7
  let(:client_id_cookie) { 'abcd' }
7
8
  let(:client_id_header) { 'abcde' }
8
9
  let(:cookies) { request.cookies }