diff --git a/lib/castle.rb b/lib/castle.rb index 33a7de74..79da1018 100644 --- a/lib/castle.rb +++ b/lib/castle.rb @@ -29,21 +29,27 @@ castle/commands/approve_device castle/commands/authenticate castle/commands/end_impersonation + castle/commands/filter castle/commands/get_device castle/commands/get_devices_for_user castle/commands/identify + castle/commands/log castle/commands/report_device castle/commands/review + castle/commands/risk castle/commands/start_impersonation castle/commands/track castle/api/approve_device castle/api/authenticate castle/api/end_impersonation + castle/api/filter castle/api/get_device castle/api/get_devices_for_user castle/api/identify + castle/api/log castle/api/report_device castle/api/review + castle/api/risk castle/api/start_impersonation castle/api/track castle/payload/prepare diff --git a/lib/castle/api/filter.rb b/lib/castle/api/filter.rb new file mode 100644 index 00000000..3ec3b745 --- /dev/null +++ b/lib/castle/api/filter.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Castle + module API + # Module for filter endpoint + module Filter + class << self + # @param options [Hash] + # return [Hash] + def call(options = {}) + unless options[:no_symbolize] + options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) + end + options.delete(:no_symbolize) + http = options.delete(:http) + config = options.delete(:config) || Castle.config + + response = Castle::API.call( + Castle::Commands::Filter.build(options), + {}, + http, + config + ) + response.merge(failover: false, failover_reason: nil) + rescue Castle::RequestError, Castle::InternalServerError => e + unless config.failover_strategy == :throw + return Castle::Failover::PrepareResponse.new(options[:user][:id], reason: e.to_s).call + end + + raise e + end + end + end + end +end diff --git a/lib/castle/api/log.rb b/lib/castle/api/log.rb new file mode 100644 index 00000000..96525efa --- /dev/null +++ b/lib/castle/api/log.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Castle + module API + # Module for log endpoint + module Log + class << self + # @param options [Hash] + # return [Hash] + def call(options = {}) + unless options[:no_symbolize] + options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) + end + options.delete(:no_symbolize) + http = options.delete(:http) + config = options.delete(:config) || Castle.config + + response = Castle::API.call( + Castle::Commands::Log.build(options), + {}, + http, + config + ) + response.merge(failover: false, failover_reason: nil) + rescue Castle::RequestError, Castle::InternalServerError => e + unless config.failover_strategy == :throw + return Castle::Failover::PrepareResponse.new(options[:user][:id], reason: e.to_s).call + end + + raise e + end + end + end + end +end diff --git a/lib/castle/api/risk.rb b/lib/castle/api/risk.rb new file mode 100644 index 00000000..be350e77 --- /dev/null +++ b/lib/castle/api/risk.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Castle + module API + # Module for risk endpoint + module Risk + class << self + # @param options [Hash] + # return [Hash] + def call(options = {}) + unless options[:no_symbolize] + options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) + end + options.delete(:no_symbolize) + http = options.delete(:http) + config = options.delete(:config) || Castle.config + + response = Castle::API.call( + Castle::Commands::Risk.build(options), + {}, + http, + config + ) + response.merge(failover: false, failover_reason: nil) + rescue Castle::RequestError, Castle::InternalServerError => e + unless config.failover_strategy == :throw + return Castle::Failover::PrepareResponse.new(options[:user][:id], reason: e.to_s).call + end + + raise e + end + end + end + end +end diff --git a/lib/castle/client.rb b/lib/castle/client.rb index 1f1fad29..563d9b27 100644 --- a/lib/castle/client.rb +++ b/lib/castle/client.rb @@ -59,6 +59,46 @@ def track(options = {}) Castle::API::Track.call(options.merge(context: new_context, no_symbolize: true)) end + # @param options [Hash] + def filter(options = {}) + options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) + + return generate_do_not_track_response(options[:user][:id]) unless tracked? + + add_timestamp_if_necessary(options) + + new_context = Castle::Context::Merge.call(@context, options[:context]) + + Castle::API::Filter.call(options.merge(context: new_context, no_symbolize: true)) + end + + # @param options [Hash] + def risk(options = {}) + options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) + + return generate_do_not_track_response(options[:user][:id]) unless tracked? + + add_timestamp_if_necessary(options) + + new_context = Castle::Context::Merge.call(@context, options[:context]) + + Castle::API::Risk.call(options.merge(context: new_context, no_symbolize: true)) + end + + # @param options [Hash] + def log(options = {}) + options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) + + return generate_do_not_track_response(options[:user][:id]) unless tracked? + + add_timestamp_if_necessary(options) + + new_context = Castle::Context::Merge.call(@context, options[:context]) + + Castle::API::Log.call(options.merge(context: new_context, no_symbolize: true)) + end + + # @param options [Hash] def start_impersonation(options = {}) options = Castle::Utils::DeepSymbolizeKeys.call(options || {}) diff --git a/lib/castle/commands/filter.rb b/lib/castle/commands/filter.rb new file mode 100644 index 00000000..062e47ed --- /dev/null +++ b/lib/castle/commands/filter.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Castle + module Commands + # Generates the payload for the filter request + class Filter + class << self + # @param options [Hash] + # @return [Castle::Command] + def build(options = {}) + Castle::Validators::Present.call(options, %i[event]) + context = Castle::Context::Sanitize.call(options[:context]) + + Castle::Command.new( + 'filter', + options.merge(context: context, sent_at: Castle::Utils::GetTimestamp.call), + :post + ) + end + end + end + end +end diff --git a/lib/castle/commands/log.rb b/lib/castle/commands/log.rb new file mode 100644 index 00000000..885c3ffb --- /dev/null +++ b/lib/castle/commands/log.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Castle + module Commands + # Generates the payload for the log request + class Log + class << self + # @param options [Hash] + # @return [Castle::Command] + def build(options = {}) + Castle::Validators::Present.call(options, %i[event]) + context = Castle::Context::Sanitize.call(options[:context]) + + Castle::Command.new( + 'log', + options.merge(context: context, sent_at: Castle::Utils::GetTimestamp.call), + :post + ) + end + end + end + end +end diff --git a/lib/castle/commands/risk.rb b/lib/castle/commands/risk.rb new file mode 100644 index 00000000..8da46bc5 --- /dev/null +++ b/lib/castle/commands/risk.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Castle + module Commands + # Generates the payload for the risk request + class Risk + class << self + # @param options [Hash] + # @return [Castle::Command] + def build(options = {}) + Castle::Validators::Present.call(options, %i[event]) + context = Castle::Context::Sanitize.call(options[:context]) + + Castle::Command.new( + 'risk', + options.merge(context: context, sent_at: Castle::Utils::GetTimestamp.call), + :post + ) + end + end + end + end +end diff --git a/spec/lib/castle/api/filter_spec.rb b/spec/lib/castle/api/filter_spec.rb new file mode 100644 index 00000000..42bc5382 --- /dev/null +++ b/spec/lib/castle/api/filter_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +describe Castle::API::Filter do + pending +end diff --git a/spec/lib/castle/api/log_spec.rb b/spec/lib/castle/api/log_spec.rb new file mode 100644 index 00000000..21a7607f --- /dev/null +++ b/spec/lib/castle/api/log_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +describe Castle::API::Log do + pending +end diff --git a/spec/lib/castle/api/risk_spec.rb b/spec/lib/castle/api/risk_spec.rb new file mode 100644 index 00000000..6bc6ce5e --- /dev/null +++ b/spec/lib/castle/api/risk_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +describe Castle::API::Risk do + pending +end diff --git a/spec/lib/castle/client_spec.rb b/spec/lib/castle/client_spec.rb index c21efb03..f304e345 100644 --- a/spec/lib/castle/client_spec.rb +++ b/spec/lib/castle/client_spec.rb @@ -375,4 +375,16 @@ it { expect(client).to be_tracked } end end + + describe 'filter' do + it_behaves_like 'action request', :filter + end + + describe 'risk' do + it_behaves_like 'action request', :risk + end + + describe 'log' do + it_behaves_like 'action request', :log + end end diff --git a/spec/lib/castle/commands/filter_spec.rb b/spec/lib/castle/commands/filter_spec.rb new file mode 100644 index 00000000..b97af83a --- /dev/null +++ b/spec/lib/castle/commands/filter_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +describe Castle::Commands::Filter do + subject(:instance) { described_class } + + let(:context) { { test: { test1: '1' } } } + let(:user) { { id: '1234', email: 'foobar@mail.com' } } + let(:default_payload) do + { + request_token: '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302', + event: '$registration', + user: user, + sent_at: time_auto, + context: context + } + end + let(:time_now) { Time.now } + let(:time_auto) { time_now.utc.iso8601(3) } + + before { Timecop.freeze(time_now) } + + after { Timecop.return } + + describe '.build' do + subject(:command) { instance.build(payload) } + + context 'with properties' do + let(:payload) { default_payload.merge(properties: { test: '1' }) } + let(:command_data) do + default_payload.merge(properties: { test: '1' }, context: context) + end + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('filter') } + it { expect(command.data).to be_eql(command_data) } + end + + context 'with user_traits' do + let(:payload) { default_payload.merge(user_traits: { test: '1' }) } + let(:command_data) do + default_payload.merge(user_traits: { test: '1' }, context: context) + end + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('filter') } + it { expect(command.data).to be_eql(command_data) } + end + + context 'when active true' do + let(:payload) { default_payload.merge(context: context.merge(active: true)) } + let(:command_data) do + default_payload.merge(context: context.merge(active: true)) + end + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('filter') } + it { expect(command.data).to be_eql(command_data) } + end + + context 'when active false' do + let(:payload) { default_payload.merge(context: context.merge(active: false)) } + let(:command_data) do + default_payload.merge(context: context.merge(active: false)) + end + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('filter') } + it { expect(command.data).to be_eql(command_data) } + end + + context 'when active string' do + let(:payload) { default_payload.merge(context: context.merge(active: 'string')) } + let(:command_data) { default_payload.merge(context: context) } + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('filter') } + it { expect(command.data).to be_eql(command_data) } + end + end + + describe '#validate!' do + subject(:validate!) { instance.build(payload) } + + context 'with event not present' do + let(:payload) { {} } + + it do + expect do + validate! + end.to raise_error(Castle::InvalidParametersError, 'event is missing or empty') + end + end + + context 'with user not present' do + let(:payload) { { event: '$login' } } + + it { expect { validate! }.not_to raise_error } + end + + context 'with event and user present' do + let(:payload) { { event: '$login', user: user } } + + it { expect { validate! }.not_to raise_error } + end + end +end diff --git a/spec/lib/castle/commands/log_spec.rb b/spec/lib/castle/commands/log_spec.rb new file mode 100644 index 00000000..e5777c51 --- /dev/null +++ b/spec/lib/castle/commands/log_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +describe Castle::Commands::Log do + subject(:instance) { described_class } + + let(:context) { { test: { test1: '1' } } } + let(:user) { { id: '1234', email: 'foobar@mail.com' } } + let(:default_payload) do + { + request_token: '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302', + event: '$login', + status: '$failed', + user: user, + sent_at: time_auto, + context: context + } + end + let(:time_now) { Time.now } + let(:time_auto) { time_now.utc.iso8601(3) } + + before { Timecop.freeze(time_now) } + + after { Timecop.return } + + describe '.build' do + subject(:command) { instance.build(payload) } + + context 'with properties' do + let(:payload) { default_payload.merge(properties: { test: '1' }) } + let(:command_data) do + default_payload.merge(properties: { test: '1' }, context: context) + end + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('log') } + it { expect(command.data).to be_eql(command_data) } + end + + context 'with user_traits' do + let(:payload) { default_payload.merge(user_traits: { test: '1' }) } + let(:command_data) do + default_payload.merge(user_traits: { test: '1' }, context: context) + end + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('log') } + it { expect(command.data).to be_eql(command_data) } + end + + context 'when active true' do + let(:payload) { default_payload.merge(context: context.merge(active: true)) } + let(:command_data) do + default_payload.merge(context: context.merge(active: true)) + end + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('log') } + it { expect(command.data).to be_eql(command_data) } + end + + context 'when active false' do + let(:payload) { default_payload.merge(context: context.merge(active: false)) } + let(:command_data) do + default_payload.merge(context: context.merge(active: false)) + end + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('log') } + it { expect(command.data).to be_eql(command_data) } + end + + context 'when active string' do + let(:payload) { default_payload.merge(context: context.merge(active: 'string')) } + let(:command_data) { default_payload.merge(context: context) } + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('log') } + it { expect(command.data).to be_eql(command_data) } + end + end + + describe '#validate!' do + subject(:validate!) { instance.build(payload) } + + context 'with event not present' do + let(:payload) { {} } + + it do + expect do + validate! + end.to raise_error(Castle::InvalidParametersError, 'event is missing or empty') + end + end + + context 'with user not present' do + let(:payload) { { event: '$login' } } + + it { expect { validate! }.not_to raise_error } + end + + context 'with event and user present' do + let(:payload) { { event: '$login', user: user } } + + it { expect { validate! }.not_to raise_error } + end + end +end diff --git a/spec/lib/castle/commands/risk_spec.rb b/spec/lib/castle/commands/risk_spec.rb new file mode 100644 index 00000000..4bfc7839 --- /dev/null +++ b/spec/lib/castle/commands/risk_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +describe Castle::Commands::Risk do + subject(:instance) { described_class } + + let(:context) { { test: { test1: '1' } } } + let(:user) { { id: '1234', email: 'foobar@mail.com' } } + let(:default_payload) do + { + request_token: '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302', + event: '$login', + status: '$succeeded', + user: user, + sent_at: time_auto, + context: context + } + end + let(:time_now) { Time.now } + let(:time_auto) { time_now.utc.iso8601(3) } + + before { Timecop.freeze(time_now) } + + after { Timecop.return } + + describe '.build' do + subject(:command) { instance.build(payload) } + + context 'with properties' do + let(:payload) { default_payload.merge(properties: { test: '1' }) } + let(:command_data) do + default_payload.merge(properties: { test: '1' }, context: context) + end + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('risk') } + it { expect(command.data).to be_eql(command_data) } + end + + context 'with user_traits' do + let(:payload) { default_payload.merge(user_traits: { test: '1' }) } + let(:command_data) do + default_payload.merge(user_traits: { test: '1' }, context: context) + end + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('risk') } + it { expect(command.data).to be_eql(command_data) } + end + + context 'when active true' do + let(:payload) { default_payload.merge(context: context.merge(active: true)) } + let(:command_data) do + default_payload.merge(context: context.merge(active: true)) + end + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('risk') } + it { expect(command.data).to be_eql(command_data) } + end + + context 'when active false' do + let(:payload) { default_payload.merge(context: context.merge(active: false)) } + let(:command_data) do + default_payload.merge(context: context.merge(active: false)) + end + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('risk') } + it { expect(command.data).to be_eql(command_data) } + end + + context 'when active string' do + let(:payload) { default_payload.merge(context: context.merge(active: 'string')) } + let(:command_data) { default_payload.merge(context: context) } + + it { expect(command.method).to be_eql(:post) } + it { expect(command.path).to be_eql('risk') } + it { expect(command.data).to be_eql(command_data) } + end + end + + describe '#validate!' do + subject(:validate!) { instance.build(payload) } + + context 'with event not present' do + let(:payload) { {} } + + it do + expect do + validate! + end.to raise_error(Castle::InvalidParametersError, 'event is missing or empty') + end + end + + context 'with user not present' do + let(:payload) { { event: '$login' } } + + it { expect { validate! }.not_to raise_error } + end + + context 'with event and user present' do + let(:payload) { { event: '$login', user: user } } + + it { expect { validate! }.not_to raise_error } + end + end +end diff --git a/spec/support/shared_examples/action_request.rb b/spec/support/shared_examples/action_request.rb new file mode 100644 index 00000000..da415393 --- /dev/null +++ b/spec/support/shared_examples/action_request.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +RSpec.shared_examples_for 'action request' do |action| + subject(:request_response) { client.send(action.to_sym, options) } + + let(:options) do + { + request_token: request_token, + event: event, + status: status, + user: user, + context: context, + properties: properties + } + end + let(:request_token) { '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302' } + let(:event) { '$login' } + let(:status) { '$succeeded' } + let(:user) { { id: '1234', email: 'foobar@mail.com' } } + let(:properties) { {} } + let(:request_body) do + { + request_token: request_token, + event: event, + status: status, + user: user, + context: context, + properties: properties, + timestamp: time_auto, + sent_at: time_auto + } + end + + context 'when used with symbol keys' do + it do + request_response + + assert_requested :post, "https://api.castle.io/v1/#{action}", times: 1 do |req| + JSON.parse(req.body) == JSON.parse(request_body.to_json) + end + end + + context 'when passed timestamp in options and no defined timestamp' do + let(:client) { client_with_no_timestamp } + + before do + options[:timestamp] = time_user + request_body[:timestamp] = time_user + + request_response + end + + it do + assert_requested :post, "https://api.castle.io/v1/#{action}", times: 1 do |req| + JSON.parse(req.body) == JSON.parse(request_body.to_json) + end + end + end + + context 'with client initialized with timestamp' do + let(:client) { client_with_user_timestamp } + + before do + request_body[:timestamp] = time_user + + request_response + end + + it do + assert_requested :post, "https://api.castle.io/v1/#{action}", times: 1 do |req| + JSON.parse(req.body) == JSON.parse(request_body.to_json) + end + end + end + end + + context 'when used with string keys' do + before do + options.deep_stringify_keys! + + request_response + end + + it do + assert_requested :post, "https://api.castle.io/v1/#{action}", times: 1 do |req| + JSON.parse(req.body) == JSON.parse(request_body.to_json) + end + end + end + + context 'when tracking enabled' do + before { request_response } + + it do + assert_requested :post, "https://api.castle.io/v1/#{action}", times: 1 do |req| + JSON.parse(req.body) == JSON.parse(request_body.to_json) + end + end + + it { expect(request_response[:failover]).to be false } + it { expect(request_response[:failover_reason]).to be_nil } + end + + context 'when tracking disabled' do + before do + client.disable_tracking + request_response + end + + it { assert_not_requested :post, "https://api.castle.io/v1/#{action}" } + it { expect(request_response[:action]).to be_eql(Castle::Verdict::ALLOW) } + it { expect(request_response[:user_id]).to be_eql('1234') } + it { expect(request_response[:failover]).to be true } + it { expect(request_response[:failover_reason]).to be_eql('Castle is set to do not track.') } + end + + context 'when request with fail' do + before do + allow(Castle::API).to receive(:send_request).and_raise( + Castle::RequestError.new(Timeout::Error) + ) + end + + context 'with request error and throw strategy' do + before { allow(Castle.config).to receive(:failover_strategy).and_return(:throw) } + + it { expect { request_response }.to raise_error(Castle::RequestError) } + end + + context 'with request error and not throw on eg deny strategy' do + it { assert_not_requested :post, "https:/:secret@api.castle.io/v1/#{action}" } + it { expect(request_response[:action]).to be_eql('allow') } + it { expect(request_response[:user_id]).to be_eql('1234') } + it { expect(request_response[:failover]).to be true } + it { expect(request_response[:failover_reason]).to be_eql('Castle::RequestError') } + end + end + + context 'when request is internal server error' do + before do + allow(Castle::API).to receive(:send_request).and_raise(Castle::InternalServerError) + end + + describe 'throw strategy' do + before { allow(Castle.config).to receive(:failover_strategy).and_return(:throw) } + + it { expect { request_response }.to raise_error(Castle::InternalServerError) } + end + + describe 'not throw on eg deny strategy' do + it { assert_not_requested :post, "https:/:secret@api.castle.io/v1/#{action}" } + it { expect(request_response[:action]).to be_eql('allow') } + it { expect(request_response[:user_id]).to be_eql('1234') } + it { expect(request_response[:failover]).to be true } + it { expect(request_response[:failover_reason]).to be_eql('Castle::InternalServerError') } + end + end +end