castle-rb 3.6.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 }