diff --git a/.rubocop.yml b/.rubocop.yml index 87e545f..51269ba 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -8,4 +8,4 @@ AllCops: - "db/*/*" Metrics/BlockLength: Exclude: - - "spec/*/*" + - "spec/**/*" diff --git a/Gemfile b/Gemfile index f7049c2..58d7ace 100644 --- a/Gemfile +++ b/Gemfile @@ -14,7 +14,7 @@ gem 'puma', '~> 4.1' # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder gem 'jbuilder', '~> 2.7' # Use Redis adapter to run Action Cable in production -# gem 'redis', '~> 4.0' +gem 'redis', '~> 4.0' # Use Active Model has_secure_password gem 'bcrypt', '~> 3.1.7' gem 'jwt' @@ -46,8 +46,10 @@ group :development do end group :test do + gem 'factory_bot_rails' # Generate test coverage reports gem 'simplecov', require: false + gem 'webmock' end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem diff --git a/Gemfile.lock b/Gemfile.lock index f6f76f8..c9105a8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -56,6 +56,8 @@ GEM minitest (~> 5.1) tzinfo (~> 1.1) zeitwerk (~> 2.2, >= 2.2.2) + addressable (2.7.0) + public_suffix (>= 2.0.2, < 5.0) ast (2.4.1) bcrypt (3.1.16) bootsnap (1.5.0) @@ -65,13 +67,20 @@ GEM coderay (1.1.3) concurrent-ruby (1.1.7) connection_pool (2.2.3) + crack (0.4.4) crass (1.0.6) diff-lcs (1.4.4) docile (1.3.2) erubi (1.9.0) + factory_bot (6.1.0) + activesupport (>= 5.0.0) + factory_bot_rails (6.1.0) + factory_bot (~> 6.1.0) + railties (>= 5.0.0) ffi (1.13.1) globalid (0.4.2) activesupport (>= 4.2.0) + hashdiff (1.0.1) i18n (1.8.5) concurrent-ruby (~> 1.0) jbuilder (2.10.1) @@ -103,6 +112,7 @@ GEM pry (0.13.1) coderay (~> 1.1) method_source (~> 1.0) + public_suffix (4.0.6) puma (4.3.6) nio4r (~> 2.0) rack (2.2.3) @@ -197,6 +207,10 @@ GEM tzinfo (1.2.8) thread_safe (~> 0.1) unicode-display_width (1.7.0) + webmock (3.10.0) + addressable (>= 2.3.6) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket-driver (0.7.3) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -209,6 +223,7 @@ DEPENDENCIES bcrypt (~> 3.1.7) bootsnap (>= 1.5.0) byebug + factory_bot_rails jbuilder (~> 2.7) jwt listen (~> 3.2) @@ -217,6 +232,7 @@ DEPENDENCIES puma (~> 4.1) rack-cors rails (~> 6.0.3, >= 6.0.3.4) + redis (~> 4.0) rspec-rails (~> 4.0.1) rubocop sidekiq @@ -224,6 +240,7 @@ DEPENDENCIES spring spring-watcher-listen (~> 2.0.0) tzinfo-data + webmock RUBY VERSION ruby 2.6.6p146 diff --git a/app/controllers/bridges_controller.rb b/app/controllers/bridges_controller.rb index 5892bc4..ec9f59f 100644 --- a/app/controllers/bridges_controller.rb +++ b/app/controllers/bridges_controller.rb @@ -65,7 +65,7 @@ def bridge_params params.require(:bridge).permit( :active, :title, - :method, + :http_method, :retries, :delay, :outbound_url, diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index e1ba1d2..230e39f 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1,68 +1,88 @@ # frozen_string_literal: true class EventsController < ApplicationController - before_action :authorize_request - before_action :set_user - # Needs to find all events based on bridge_id or event_id - # Needs to return id for each event as well - # def index - # events = @current_user.bridges - # events.map! do |event| - # updated_at = String(event.updated_at) - # date = date_format(updated_at.split(' ')[1]) - # time = updated_at.split(' ')[0] - # { id: 1, - # time: time, - # date: date, - # status_code: event.status_code } - # end - # render json: { events: events }, status: 200 # OK - # end + before_action :authorize_request, except: :create + before_action :set_event, only: %i[show destroy] - def show; end + def index + if fetch_events.empty? + render json: { error: 'invalid parameters' }, status: 400 # Bad Request + else + render json: { events: @events }, status: 200 + end + end + + def show + if @event + render json: { event: @event }, status: 200 + else + render json: { error: 'an event by that id was not found' }, status: 400 + end + end + + def destroy + return render json: {}, status: 204 if @event&.destroy + + render json: { error: 'an event by that id was not found' }, status: 400 + end - # receive bridge id + data def create - bridge = Bridge.find(params[:id]) - data = { inbound: request.body, outbounds: [] } - event = Event.new(data: data, bridge_id: bridge.id) + event = create_event(find_bridge) if event.save - # EventWorker.perform(id of event just created) - status 201 # Created + EventWorker.perform_async(event.id) + render json: {}, status: 202 # Accepted else - status 400 # Bad Request + render json: { error: 'Invalid parameters' }, status: 400 # Bad Request end + rescue JSON::ParserError + render json: { error: 'Invalid request. Payload must be in JSON' }, status: 400 # Bad Request end private - def date_format(_date) - year = time.split('-')[0] - month = time.split('-')[1] - day = time.split('-')[2] - "#{year}-#{month}-#{day}" + def event_params + params.permit(:id, :bridge_id, :event_id, :test) end - def set_user - # bridge_id or #event_id - # => User + def fetch_events + @events = if event_params[:bridge_id] + Event.where(bridge_id: event_params[:bridge_id]).order(completed_at: :desc).limit(100) + elsif event_params[:event_id] + Event.where(bridge_id: find_event&.bridge_id).order(completed_at: :desc).limit(100) + else + [] # Prevent nil + end end -end -# Step 1 -# Convert binary to jsonb + def data + { + 'inbound' => { + 'payload' => JSON.parse(request.body.read), + 'dateTime' => DateTime.now.utc, + 'ip' => request.ip, + 'contentLength' => request.content_length + }, + 'outbound' => [] + } + end -# Step 2 -# payload: -{ - test: 'user entered string from editor', - production: 'user entered string from editor' -} + def create_event(bridge) + Event.new( + data: data.to_json, + bridge_id: bridge&.id, + test: event_params[:test] || false + ) + end -# Step 3 -# + def set_event + @event = find_event + end -# NB: Need to run `bundle exec sidekiq` in separate terminal -# localhost:3000/sidekiq to monitor sidekiq while running -# after turning it on -# => mount Sidekiq::Web => '/sidekiq + def find_event + Event.find_by(id: event_params[:event_id]) + end + + def find_bridge + Bridge.find_by(id: event_params[:bridge_id]) + end +end diff --git a/app/lib/bridge_api.rb b/app/lib/bridge_api.rb new file mode 100644 index 0000000..f8b6c74 --- /dev/null +++ b/app/lib/bridge_api.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require_relative './invalid_payload_key' +require_relative './invalid_environment_variable' +require_relative './sidekiq/large_status_code' + +module BridgeApi +end diff --git a/app/lib/bridge_api/http.rb b/app/lib/bridge_api/http.rb new file mode 100644 index 0000000..718d919 --- /dev/null +++ b/app/lib/bridge_api/http.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module BridgeApi + module Http + end +end diff --git a/app/lib/bridge_api/http/builder.rb b/app/lib/bridge_api/http/builder.rb new file mode 100644 index 0000000..f774b21 --- /dev/null +++ b/app/lib/bridge_api/http/builder.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +module BridgeApi + module Http + # Handles building a HTTP Request object. Will parse user defined payloads + # and headers into expected values. Accepts custom parsers for extending. + # + # Example: + # + # ```ruby + # event = Event.find(1) + # + # handler = BridgeApi::Http::RequestHandler.new event + # builder = BridgeApi::Http::Builder.new handler, handler.payload_parser, handler.headers_parser + # + # builder.generate # => returns an Tuple containing Net::Http & Net::Http::{user_request_type} objects + # ``` + class Builder + SCHEME = 'https://' + + include Interfaces::Builder + + # @param [BridgeApi::Http::Handler] request_handler - Used for delegation + # @param [BridgeApi::SyntaxParser::Interfaces::PayloadParser] payload_parser + # @param [BridgeApi::SyntaxParser::Interfaces::HeadersParser] headers_parser + def initialize(request_handler, payload_parser, headers_parser) + @request_handler = request_handler + @payload_parser = payload_parser + @headers_parser = headers_parser + end + + # Generate & return `Net::HTTP`(net_http) & `Net::HTTP::{http_method}`(http_request) objects + # + # @return [Tuple(Net::HTTP, Net::HTTP::{http_method})] + def generate + [net_http, http_request] + end + + private + + delegate :bridge, to: :request_handler + delegate :event, to: :request_handler + + attr_reader :request, + :request_handler, + :payload_parser, + :headers_parser + + def net_http + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + + http + end + + def http_request + @request = net_http_request + parse_headers! + request.body = parsed_payload.to_json + + request + end + + # Convert user defined outbound_url into a valid URI + # + # @return [String] + def uri + @uri ||= URI(scheme) + end + + # Sets the user defined headers into the `request` object + def parse_headers! + headers_parser.parse(headers) do |key, value| + request[key] = value + end + end + + # Parse our custom syntax into the expected values + # + # @return [Hash(String, String)] + def parsed_payload + @parsed_payload ||= payload_parser.parse( + event.inbound_payload, + JSON.parse(unparsed_payload) + ) + end + + # Returns either payload or test_payload depending + # on the event environment + # + # @return [JSON] + def unparsed_payload + @unparsed_payload ||= data['payload'] + end + + # Ensures the scheme used is using TSL + # + # @return [String] + def scheme + return outbound_url if outbound_url.starts_with?('https') + + "#{SCHEME}#{outbound_url.split('//').last}" + end + + # Create a HTTP request object based on the user defined + # HTTP method. + # + # @return [Net::HTTP::{http_method}] + def net_http_request + "Net::HTTP::#{http_method}".constantize.new(uri, 'Content-Type' => 'application/json') + end + + # @return [String] + def http_method + bridge.http_method.capitalize + end + + # @return [Hash(String, JSON)] + def data + bridge.data + end + + # @return [String] + def outbound_url + bridge.outbound_url + end + + # @return [ActiveRecord::Relation(Header)] + def headers + @headers ||= bridge.headers + end + end + end +end diff --git a/app/lib/bridge_api/http/deconstructor.rb b/app/lib/bridge_api/http/deconstructor.rb new file mode 100644 index 0000000..c9a7c85 --- /dev/null +++ b/app/lib/bridge_api/http/deconstructor.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module BridgeApi + module Http + # Deconstructs the request headers. Replaces + # any headers that contain environment variables with + # placeholder values to prevent data leakage. + # + # Example: + # + # ```ruby + # event = Event.find 1 + # deconstructor = BridgeApi::Http::Deconstructor.new event.bridge.headers + # safe_headers = [] + # + # request = Net::HTTP.start(uri.host, uri.port) do |http| + # request = Net::HTTP::Get.new uri + # set_your_decrypted_headers(request) + # request + # end + # + # req.each_header do |key, value| + # save_headers << { + # key: key, + # value: deconstructor.deconstruct(key, value) + # } + # end + # ``` + class Deconstructor + include Interfaces::Deconstructor + + # @param [ActiveRecord::Relation(Header)] user_headers + def initialize(headers) + # Remove any headers that don't contain `$env` as they + # don't need to be filtered. Keys are downcased because + # `request.each_header` returns headers that are downcased. + @header_keys = headers.where('value like ?', '$env%').pluck(:key).map(&:downcase) + end + + # Prevents sensitive headers from being stored in cleared text. + # If the header value can be found & the value contains `$env` + # we return a safe value otherwise the real value is returned. + # + # @param [String] key + # @param [String] value + # + # @return [String] + def deconstruct(key, value) + header_keys.include?(key) ? 'FILTERED' : value + end + + private + + attr_reader :headers, :header_keys + end + end +end diff --git a/app/lib/bridge_api/http/formatter.rb b/app/lib/bridge_api/http/formatter.rb new file mode 100644 index 0000000..99435f2 --- /dev/null +++ b/app/lib/bridge_api/http/formatter.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module BridgeApi + module Http + # Handles formatting requests & responses. Accepts a deconstructor + # to remove sensitive headers. + # + # Example: + # + # ```ruby + # event = Event.find 1 + # deconstructor = BridgeApi::Http::Deconstructor.new event.bridge.headers + # formatter = BridgeApi::Http::Formatter.new deconstructor + # uri = URI('http://example.com/some_path?query=string') + # + # Net::HTTP.start(uri.host, uri.port) do |http| + # request = Net::HTTP::Get.new uri + # response = http.request request # Net::HTTPResponse object + # formatter.format! event, request, response + # resuce StandardError => e + # formatter.format_error! event, request, e + # end + # ``` + class Formatter + include Interfaces::Formatter + + # @param [BridgeApi::Http::Interfaces::Deconstructor] deconstructor + def initialize(deconstructor) + @deconstructor = deconstructor + end + + # Mutates the event object by storing a formatted request + # & response inside `event.data` + # + # @param [Event] event + # @param [Net::HTTP] req + # @param [Net::HTTPResponse] res + def format!(event, req, res) + data = JSON.parse(event.data) + + data['outbound'].push({ + 'request' => formatted_request(req), + 'response' => formatted_response(res) + }) + event.data = data.to_json + end + + # Mutates the event object by storing a formatted request + # & error inside `event.data`. + # + # @param [Event] event + # @param [Net::HTTP] req + # @param [StandardError] error + def format_error!(event, req, error) + data = JSON.parse(event.data) + + data['outbound'].push({ + 'request' => formatted_request(req), + 'response' => formatted_error(error) + }) + event.data = data.to_json + end + + private + + attr_reader :deconstructor + + # Formats the response without saving the entire object. + # + # @param [Net::HTTPResponse] res + # + # @return [Hash(String, To many unions)] + def formatted_response(response) + { + dateTime: DateTime.now.utc, + statusCode: response.code, + message: response.message, + size: response.size, + payload: response.code.to_i >= 300 ? {} : JSON.parse(response.body) + } + end + + # Formats the request without saving the entire object. + # + # @param [Net::HTTPResponse] req + # + # @return [Hash(Symbol, To many unions)] + def formatted_request(request) + { + payload: JSON.parse(request.body), + dateTime: DateTime.now.utc, + contentLength: request['content-length'], + uri: request.uri.to_s, + headers: safe_headers(request) + } + end + + # @param [StandardError] error + # + # @return [Hash(Symbol, String)] + def formatted_error(error) + { message: error.message } + end + + # Handles creating an Array of all request headers + # + # @param [Net::HTTP] req + # + # @return [Array(Hash(String, String))] + def safe_headers(req) + headers = [] + req.each_header do |key, value| + headers << { + key: key, + value: deconstructor.deconstruct(key, value) + } + end + + headers + end + end + end +end diff --git a/app/lib/bridge_api/http/interfaces.rb b/app/lib/bridge_api/http/interfaces.rb new file mode 100644 index 0000000..f0e8962 --- /dev/null +++ b/app/lib/bridge_api/http/interfaces.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module BridgeApi + module Http + module Interfaces + end + end +end diff --git a/app/lib/bridge_api/http/interfaces/builder.rb b/app/lib/bridge_api/http/interfaces/builder.rb new file mode 100644 index 0000000..4d820e7 --- /dev/null +++ b/app/lib/bridge_api/http/interfaces/builder.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module BridgeApi + module Http + module Interfaces + # Abstract "class" + module Builder + def generate + raise NotImplementedError, 'A request builder class must implement #generate' + end + end + end + end +end diff --git a/app/lib/bridge_api/http/interfaces/deconstructor.rb b/app/lib/bridge_api/http/interfaces/deconstructor.rb new file mode 100644 index 0000000..149b7a3 --- /dev/null +++ b/app/lib/bridge_api/http/interfaces/deconstructor.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module BridgeApi + module Http + module Interfaces + # Abstract "class" + module Deconstructor + def deconstruct(_key, _value) + raise NotImplementedError, 'A request builder class must implement #deconstruct(key, value)' + end + end + end + end +end diff --git a/app/lib/bridge_api/http/interfaces/formatter.rb b/app/lib/bridge_api/http/interfaces/formatter.rb new file mode 100644 index 0000000..d6201e5 --- /dev/null +++ b/app/lib/bridge_api/http/interfaces/formatter.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module BridgeApi + module Http + module Interfaces + # Abstract "class" + module Formatter + def format!(_event, _req, _res) + raise NotImplementedError, 'A request builder class must implement #format!(event, req, res)' + end + + def format_error!(_event, _req, _error) + raise NotImplementedError, 'A request builder class must implement #format_error!(event, req, error)' + end + end + end + end +end diff --git a/app/lib/bridge_api/http/request_handler.rb b/app/lib/bridge_api/http/request_handler.rb new file mode 100644 index 0000000..cd21161 --- /dev/null +++ b/app/lib/bridge_api/http/request_handler.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module BridgeApi + module Http + # Request handler is the main entry for our briding system. RequestHandler is the orchestrator + # managing flow & all the different pieces. + # + # Example: + # + # ```ruby + # event = Event.find(1) + # + # handler = BridgeApi::Http::Handler.new event + # handler.execute + # + # event.complete! # => events `data` attribute was mutated setting data['outbound'] with + # # {"request" => {request_data}, "response" => {response_from_server} } + # ``` + # + # ```ruby + # # Setting custom `http_builder` + # event = Event.find(1) + # + # handler = BridgeApi::Http::RequestHandler.new event + # handler.http_builder = + # XmlHttpBuilder.new handler, handler.payload_parser, handler.headers_parser + # + # handler.execute # => Will now use your XmlHttpBuilder class + # + # event.complete! # => events `data` attribute was mutated setting data['outbound'] with + # # {"request" => {request_data}, "response" => {response_from_server} } + # ``` + class RequestHandler + # Inject any piece of the request handling system for extending or testing. + # + # @param [BridgeApi::Http::Interfaces::Builder] http_builder + # @param [BridgeApi::Http::Interfaces::Formatter] formatter + # @param [BridgeApi::Http::Interfaces::Deconstructor] formatter + # @param [BridgeApi::SyntaxParser::Interfaces::HeadersParser] headers_parser + # @param [BridgeApi::SyntaxParser::Interfaces::PayloadParser] payload_parser + attr_writer :http_builder, + :formatter, + :deconstructor, + :headers_parser, + :payload_parser + + attr_reader :event, :bridge + + # @param [Event] event + def initialize(event) + @event = event + @bridge = @event.bridge + end + + # Handles building and sending a HTTP request and then + # formats and stores the data into event object. + def execute + net_http, @request = http_builder.generate + response = net_http.request request + formatter.format! event, request, response + + raise Sidekiq::LargeStatusCode if response.code.to_i >= 300 + end + + # Handles storing the request & error into the event data + def cleanup(error) + formatter.format_error! event, request, error + end + + # These next few methods are public for easy injection. + # If you wanted to inject your own http_builder but keep everything + # else as the default, that would be rather difficult without these + # being public. + + # @return [BridgeApi::Http::Interfaces::Deconstructor] + def deconstructor + @deconstructor ||= Deconstructor.new bridge.headers + end + + # @return [BridgeApi::SyntaxParser::Interfaces::HeadersParser] + def headers_parser + @headers_parser ||= SyntaxParser::HeadersParser.new bridge.environment_variables + end + + # @return [BridgeApi::SyntaxParser::Interfaces::PayloadParser] + def payload_parser + @payload_parser ||= SyntaxParser::PayloadParser.new bridge.environment_variables + end + + private + + attr_reader :request + + # @return [BridgeApi::Http::Interfaces::Builder] + def http_builder + @http_builder ||= Builder.new self, payload_parser, headers_parser + end + + # @return [BridgeApi::Http::Interfaces::Formatter] + def formatter + @formatter ||= Formatter.new deconstructor + end + end + end +end diff --git a/app/lib/bridge_api/syntax_parser.rb b/app/lib/bridge_api/syntax_parser.rb new file mode 100644 index 0000000..32fc300 --- /dev/null +++ b/app/lib/bridge_api/syntax_parser.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module BridgeApi + module SyntaxParser + end +end diff --git a/app/lib/bridge_api/syntax_parser/environment_variables.rb b/app/lib/bridge_api/syntax_parser/environment_variables.rb new file mode 100644 index 0000000..ea143cb --- /dev/null +++ b/app/lib/bridge_api/syntax_parser/environment_variables.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module BridgeApi + module SyntaxParser + module EnvironmentVariables + # Takes a value such as `$env.API_KEY`, finds the corresponding + # EnvironmentVariable & returns the decrypted value. + # + # @param [String] value + # + # @return [String] + def fetch_environment_variable(value) + key = value.split('.').last + environment_variable = environment_variables.find_by(key: key) + raise ::InvalidEnvironmentVariable unless environment_variable + + environment_variable.decrypt + end + end + end +end diff --git a/app/lib/bridge_api/syntax_parser/headers_parser.rb b/app/lib/bridge_api/syntax_parser/headers_parser.rb new file mode 100644 index 0000000..54295bd --- /dev/null +++ b/app/lib/bridge_api/syntax_parser/headers_parser.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module BridgeApi + module SyntaxParser + # This class parses user defined headers & payloads into the values we expect + # to send to the outbound service. + # + # Example: + # + # ```ruby + # request = Net::Http::Post.new URI('http://example.com/index.html?count=10') + # + # bridge = Bridge.where(user_id: @current_user.id) + # + # headers_parser = BridgeApi::SyntaxParser::HeadersParser.new bridge.environment_variables + # + # headers_parser.parse(bridge.headers) do |key, value| + # request[key] = value + # end + # ``` + class HeadersParser + include EnvironmentVariables + include Interfaces::HeadersParser + + # @param [ActiveRecord::Relation(EnvironmentVariable)] environment_variables + def initialize(environment_variables) + @environment_variables = environment_variables + end + + # Handles parsing the user's headers replacing any values containing `$env` + # with the decrypted environment variable value. Expected to be invoked + # with a block. The block will be called on each iteration of a header passing + # in `header.key` & a parsed version of `header.value` as arguments. + # + # @param [ActiveRecord::Relation(Header)] environment_variables + # @param [Proc] block + # + # @return [Array(Hash(String, String))] + def parse(headers, &block) + @headers = headers + parse_headers!(block) + end + + private + + attr_reader :environment_variables, + :headers + + # Iterates over headers, yields to a block passing in the header key & + # parsed value if parsing is required. + # + # @param [Proc] block + def parse_headers!(block) + headers.each { |header| block.call(header.key, parse_value(header.value)) } + end + + # Replaces a `$env.API_KEY` string with the decrypted + # EnvironmentVariable value. + # + # @param [String] value + # + # @return [String] + def parse_value(value) + if value.include?('$env') + fetch_environment_variable(value) + else + value + end + end + end + end +end diff --git a/app/lib/bridge_api/syntax_parser/interfaces.rb b/app/lib/bridge_api/syntax_parser/interfaces.rb new file mode 100644 index 0000000..c529498 --- /dev/null +++ b/app/lib/bridge_api/syntax_parser/interfaces.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module BridgeApi + module SyntaxParser + module Interfaces + end + end +end diff --git a/app/lib/bridge_api/syntax_parser/interfaces/headers_parser.rb b/app/lib/bridge_api/syntax_parser/interfaces/headers_parser.rb new file mode 100644 index 0000000..b622d40 --- /dev/null +++ b/app/lib/bridge_api/syntax_parser/interfaces/headers_parser.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module BridgeApi + module SyntaxParser + module Interfaces + # Abstract "class" + module HeadersParser + def parse + raise NotImplementedError, 'A header parser class must implement #parse(headers)' + end + end + end + end +end diff --git a/app/lib/bridge_api/syntax_parser/interfaces/payload_parser.rb b/app/lib/bridge_api/syntax_parser/interfaces/payload_parser.rb new file mode 100644 index 0000000..404aad0 --- /dev/null +++ b/app/lib/bridge_api/syntax_parser/interfaces/payload_parser.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module BridgeApi + module SyntaxParser + module Interfaces + # Abstract "class" + module PayloadParser + def parse + raise NotImplementedError, 'A payload parser class must implement #parse(incoming_request, user_data)' + end + end + end + end +end diff --git a/app/lib/bridge_api/syntax_parser/payload_parser.rb b/app/lib/bridge_api/syntax_parser/payload_parser.rb new file mode 100644 index 0000000..a6356ab --- /dev/null +++ b/app/lib/bridge_api/syntax_parser/payload_parser.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module BridgeApi + module SyntaxParser + # This class parses user defined headers & payloads into the values we expect + # to send to the outbound service. + # + # Example: + # + # ```ruby + # custom_user_payload = { + # 'hello' => '$payload.top_level_key', + # 'environment_variable' => '$env.API_KEY', + # 'did_you' => '$payload.nested_key_1.nested_key_2.nested_key_3' + # } + # + # incoming_payload_from_service_a = { + # 'top_level_key' => 'world', + # 'nested_key_1' => { + # 'nested_key_2' => { + # 'nested_key_3' => 'make it!' + # } + # } + # } + # + # bridge = Bridge.where(user_id: @current_user.id) + # + # payload_parser = BridgeApi::SyntaxParser::PayloadParser.new bridge.environment_variables + # + # payload_parser.parse( + # incoming_payload_from_service_a, + # custom_user_payload + # ) # => Hash(String, String) where $payload & $env are replaced with their respective values + # ``` + class PayloadParser + include EnvironmentVariables + include Interfaces::PayloadParser + + # @param [ActiveRecord::Relation(EnvironmentVariable)] environment_variables + def initialize(environment_variables) + @environment_variables = environment_variables + end + + # Parses the user's custom payload replacing any values containing `$env` + # or `$payload` with the respective value + # + # @param [Hash(String, String | Hash | Array)] incoming_payload + # @param [Hash(String, String | Hash | Array)] user_data + # + # @return [Hash(String, String | Hash | Array)] + def parse(incoming_payload, user_data) + @incoming_payload = incoming_payload + parse_payload!(user_data) + end + + private + + attr_reader :incoming_payload, # Inbound payload from service A + :environment_variables + + # Iterates through user defined payload and parse values containing `$env` or `$payload` + # + # @param [Hash(String, String | Hash | Array)] user_data + # + # @return [Hash(String, String | Hash | Array)] + def parse_payload!(user_data) + outbound_payload = {} + user_data.each { |key, val| outbound_payload[key] = parse_value(val) } + + outbound_payload + end + + # Handles parsing a value depending on its value or type. + # Will call `parse_payload!` recursively if value is a + # Hash or Array. + # + # @param [String | Hash | Array] value + # + # @return [String | Hash | Array] + # rubocop:disable Metrics/MethodLength + def parse_value(value) + if value.include?('$env') + fetch_environment_variable(value) + elsif value.include?('$payload') + fetch_payload_data(value) + elsif value.instance_of?(Hash) + parse_payload!(value) + elsif value.instance_of?(Array) + # TODO: Add support for accessing a single element + value.map { |i| parse_payload!(i) } + else + value + end + end + # rubocop:enable Metrics/MethodLength + + def fetch_payload_data(value) + values = value.split('.') + data = incoming_payload # Set data to the incoming request + + values.each_with_index do |val, idx| + next if idx.zero? # skip the $payload + + data = data[val.to_s] || data[val.to_sym] # dig deeper into the request on each iteration + raise ::InvalidPayloadKey if data.nil? + end + + data + end + end + end +end diff --git a/app/lib/invalid_environment_variable.rb b/app/lib/invalid_environment_variable.rb new file mode 100644 index 0000000..750487e --- /dev/null +++ b/app/lib/invalid_environment_variable.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class InvalidEnvironmentVariable < StandardError +end diff --git a/app/lib/invalid_payload_key.rb b/app/lib/invalid_payload_key.rb new file mode 100644 index 0000000..f7855ad --- /dev/null +++ b/app/lib/invalid_payload_key.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class InvalidPayloadKey < StandardError +end diff --git a/app/lib/sidekiq/large_status_code.rb b/app/lib/sidekiq/large_status_code.rb new file mode 100644 index 0000000..9f552dd --- /dev/null +++ b/app/lib/sidekiq/large_status_code.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Sidekiq + class LargeStatusCode < StandardError + end +end diff --git a/app/models/bridge.rb b/app/models/bridge.rb index f1bb0cf..abe6124 100644 --- a/app/models/bridge.rb +++ b/app/models/bridge.rb @@ -25,13 +25,18 @@ 5 ].freeze +# `data` column: +# { +# "payload" => {} +# "test_payload" => {} +# } class Bridge < ApplicationRecord before_validation :set_inbound_url, on: :create before_validation :set_payloads, on: :create validates :title, presence: true validates :inbound_url, presence: true, uniqueness: true validates :outbound_url, presence: true - validates :method, inclusion: METHODS + validates :http_method, inclusion: METHODS validates :delay, inclusion: DELAYS validates :retries, inclusion: RETRIES validate :validate_payloads diff --git a/app/models/environment_variable.rb b/app/models/environment_variable.rb index 781fb97..4833043 100644 --- a/app/models/environment_variable.rb +++ b/app/models/environment_variable.rb @@ -11,7 +11,7 @@ class EnvironmentVariable < ApplicationRecord delegate :encrypt_and_sign, :decrypt_and_verify, to: :encryptor KEY = ActiveSupport::KeyGenerator.new( - ENV.fetch('SECRET_KEY_BASE') || Rails.application.secrets.secret_key_base + ENV['SECRET_KEY_BASE'] || Rails.application.secrets.secret_key_base ).generate_key( ENV.fetch('ENCRYPTION_KEY_SALT'), ActiveSupport::MessageEncryptor.key_len diff --git a/app/models/event.rb b/app/models/event.rb index 42759bc..b14188e 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,7 +1,109 @@ # frozen_string_literal: true +# `data` column: +# { +# 'inbound' => { +# 'payload' => { +# 'bridge_id' => '1', +# 'top_level_key' => 'present', +# 'nested_key_1' => { +# 'nested_key_2' => 'present' +# } +# }, +# 'dateTime' => '2020-11-21T13:59:47.349Z', +# 'ip' => '0.0.0.0', +# 'contentLength' => 101 +# }, +# 'outbound' => [ +# { +# 'request' => { +# 'payload' => { +# 'first_name' => 'Lee', +# 'last_name' => 'Oswald', +# 'username' => 'GrassyKnoll', +# 'email' => 'kgb63@yandex.ru', +# 'top_level_key' => 'present', +# 'nested_key' => 'present' +# }, +# 'dateTime' => '2020-11-21T13:59:51.076Z', +# 'contentLength' => '141', +# 'uri' => 'https://c41a7126-a18c-4af6-880e-6857771a35c8.mock.pstmn.io/success_event', +# 'headers' => [ +# { +# 'key' => 'content-type', +# 'value' => 'application/json' +# }, +# { +# 'key' => 'should_be_filtered', +# 'value' => 'FILTERED' +# }, +# { +# 'key' => 'not_filtered', +# 'value' => 'bridge api' +# }, +# ] +# }, +# 'response' => { +# 'dateTime' => '2020-11-21T13:59:51.076Z', +# 'statusCode' => '200', +# 'message' => 'OK', +# 'size' => 13, +# 'payload' => { +# 'hello' => 'world' +# } +# } +# } +# ] +# } class Event < ApplicationRecord - validates :outbound_url, presence: true + before_validation :set_urls + validates :test, inclusion: [true, false] + validates :completed, inclusion: [true, false] + validates :status_code, numericality: { greater_than_or_equal_to: 100, less_than_or_equal_to: 599 }, allow_nil: true + validate :completed_at_format + validate :data_json_object belongs_to :bridge + + # Marks an event as complete & saves + def complete! + self.completed = true + self.completed_at = Time.now.utc + save! # TODO: With a bang? + end + + # Parses `data` and fetches the payload from the + # inbound request. + # + # @return [Hash(String, String)] + def inbound_payload + JSON.parse(data)['inbound']['payload'] + end + + private + + # TODO: Pass in bridge (or urls) to prevent db hit + def set_urls + self.inbound_url = bridge&.inbound_url + self.outbound_url = bridge&.outbound_url + end + + def data_json_object + data = JSON.parse(self.data) + %w[inbound outbound].all? { |key| data.include?(key) } && + %w[payload dateTime ip contentLength].all? { |key| data['inbound'].include?(key) } || + errors.add( + :data, + 'must include the keys: "inbound", "outbound", + while "inbound" must include the keys "payload", "dateTime", "ip", "contentLength"' + ) + rescue JSON::ParserError, TypeError + errors.add(:data, 'object must be a valid json object') + end + + def completed_at_format + return if completed_at.nil? || completed_at.instance_of?(ActiveSupport::TimeWithZone) + + errors.add(:completed_at, '"completed_at" must be a Time instance if event is completed') + end end diff --git a/app/workers/event_worker.rb b/app/workers/event_worker.rb index 76f0ad6..bd653f8 100644 --- a/app/workers/event_worker.rb +++ b/app/workers/event_worker.rb @@ -1,15 +1,36 @@ # frozen_string_literal: true +require 'net/http' +require_relative '../lib/sidekiq/large_status_code' + class EventWorker include Sidekiq::Worker - # takes event id argument - # find event => - # find bridge => - # git payload & outbound url - # => send it + attr_writer :request_handler + + attr_accessor :retry_count def perform(event_id) - # do something + @event = Event.includes(:bridge).find(event_id) + request_handler.execute + event.complete! + rescue StandardError => e + # We can skip clean up on error `Sidekiq::LargeStatusCode` because we did + # recieve a response and it was saved properly. + request_handler.cleanup(e) unless e.instance_of? Sidekiq::LargeStatusCode + + return event.complete! if retry_count&.>= bridge.retries # TODO: Off by one? + + # TODO: We need filter error messages. ArgumentError and our stuff can be ignored but + # should we really tell users "StandardError" if their service replied with 404? + raise StandardError + end + + private + + attr_reader :event + + def request_handler + @request_handler ||= ::BridgeApi::Http::RequestHandler.new(event) end end diff --git a/config/database.yml b/config/database.yml index e3af124..7db69ac 100644 --- a/config/database.yml +++ b/config/database.yml @@ -24,6 +24,8 @@ default: &default development: <<: *default database: bridgeapi_rb_development + username: ruby + password: ruby # The specified database role being used to connect to postgres. # To create additional roles in postgres see `$ createuser --help`. diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb new file mode 100644 index 0000000..c81fc68 --- /dev/null +++ b/config/initializers/sidekiq.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class SidekiqMiddleware + def call(worker, job, _queue) + worker.retry_count = job['retry_count'] if worker.respond_to?(:retry_count) + yield + end +end + +Sidekiq.configure_server do |config| + config.server_middleware do |chain| + chain.add SidekiqMiddleware + end +end diff --git a/config/routes.rb b/config/routes.rb index c198c71..abbe1af 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'sidekiq/web' + Rails.application.routes.draw do resource :user, except: %i[new edit] resources :bridges do @@ -9,5 +11,10 @@ resources :headers, :environment_variables, only: :destroy post 'login', to: 'sessions#create' + + post 'events/:bridge_id', to: 'events#create' get 'events', to: 'events#index' + get 'events/:event_id', to: 'events#show' + delete 'events/:event_id', to: 'events#destroy' + mount Sidekiq::Web => '/sidekiq' end diff --git a/db/dump.rdb b/db/dump.rdb new file mode 100644 index 0000000..5d95a9c Binary files /dev/null and b/db/dump.rdb differ diff --git a/db/migrate/20201111175121_change_binary_columns_to_json_b.rb b/db/migrate/20201111170705_change_binary_columns_to_json_b.rb similarity index 68% rename from db/migrate/20201111175121_change_binary_columns_to_json_b.rb rename to db/migrate/20201111170705_change_binary_columns_to_json_b.rb index 78254ec..0e9cb74 100644 --- a/db/migrate/20201111175121_change_binary_columns_to_json_b.rb +++ b/db/migrate/20201111170705_change_binary_columns_to_json_b.rb @@ -1,6 +1,11 @@ class ChangeBinaryColumnsToJsonB < ActiveRecord::Migration[6.0] - def change + def up change_column :bridges, :payload, :jsonb, using: 'payload::text::jsonb', null: false change_column :events, :data, :jsonb, using: 'data::text::jsonb', null: false end + + def down + change_column :bridges, :payload, :text + change_column :events, :data, :text + end end diff --git a/db/migrate/20201111174614_remove_default_attribute.rb b/db/migrate/20201111174614_remove_default_attribute.rb new file mode 100644 index 0000000..5975a97 --- /dev/null +++ b/db/migrate/20201111174614_remove_default_attribute.rb @@ -0,0 +1,5 @@ +class RemoveDefaultAttribute < ActiveRecord::Migration[6.0] + def change + change_column_default :bridges, :payload, nil + end +end diff --git a/db/migrate/20201111195349_remove_not_null_status_code_in_events.rb b/db/migrate/20201111195349_remove_not_null_status_code_in_events.rb new file mode 100644 index 0000000..790e3db --- /dev/null +++ b/db/migrate/20201111195349_remove_not_null_status_code_in_events.rb @@ -0,0 +1,5 @@ +class RemoveNotNullStatusCodeInEvents < ActiveRecord::Migration[6.0] + def change + change_column_null :events, :status_code, true + end +end diff --git a/db/migrate/20201113001336_add_test_to_events.rb b/db/migrate/20201113001336_add_test_to_events.rb new file mode 100644 index 0000000..37e8280 --- /dev/null +++ b/db/migrate/20201113001336_add_test_to_events.rb @@ -0,0 +1,5 @@ +class AddTestToEvents < ActiveRecord::Migration[6.0] + def change + add_column :events, :test, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20201121142638_change_bridge_method.rb b/db/migrate/20201121142638_change_bridge_method.rb new file mode 100644 index 0000000..89828b7 --- /dev/null +++ b/db/migrate/20201121142638_change_bridge_method.rb @@ -0,0 +1,5 @@ +class ChangeBridgeMethod < ActiveRecord::Migration[6.0] + def change + rename_column :bridges, :method, :http_method + end +end diff --git a/db/schema.rb b/db/schema.rb index 2d049a0..bb2bff7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_11_20_084233) do +ActiveRecord::Schema.define(version: 2020_11_21_142638) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -19,7 +19,7 @@ t.string "title", null: false t.string "inbound_url", null: false t.string "outbound_url", null: false - t.string "method", null: false + t.string "http_method", null: false t.integer "retries", null: false t.integer "delay", null: false t.jsonb "data", null: false @@ -45,11 +45,12 @@ t.jsonb "data", null: false t.string "inbound_url", null: false t.string "outbound_url", null: false - t.integer "status_code", null: false + t.integer "status_code" t.datetime "completed_at" t.bigint "bridge_id", null: false t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false + t.boolean "test", default: false, null: false t.index ["bridge_id"], name: "index_events_on_bridge_id" end diff --git a/db/seeds.rb b/db/seeds.rb index d479e43..a7052c5 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,33 +1,50 @@ +# frozen_string_literal: true + def test_url - 'doggoapi.io/' + String(rand).split('.')[1] + "doggoapi.io/#{String(rand).split('.')[1]}" end user = User.create(email: 'admin@bridge.io', password: 'password', notifications: false) user2 = User.create(email: 'tester@bridge.io', password: 'password', notifications: false) bridge = Bridge.create( - user: user, + user_id: user.id, title: 'My First Bridge', - outbound_url: 'ip.jsontest.com', - method: 'POST', + inbound_url: 'bridgeapi.com/249634', + outbound_url: 'c41a7126-a18c-4af6-880e-6857771a35c8.mock.pstmn.io/success_event', + http_method: 'POST', retries: 5, delay: 15, - data: { payload: '{}', test_payload: '{}' } + data: { + payload: { + first_name: 'Lee', + last_name: 'Oswald', + username: 'GrassyKnoll', + email: 'kgb63@yandex.ru', + top_level_key: '$payload.top_level_key', + nested_key: '$payload.nested_key_1.nested_key_2' + }.to_json, + test_payload: '{"test_key_one":"testerstring","test_key_two":["stringinarray"]}' + } ) -bridge.environment_variables << EnvironmentVariable.create(key: 'database', value: 'a102345ij2') +bridge.environment_variables << EnvironmentVariable.create(key: 'DATABASE_URL', value: 'a102345ij2') bridge.environment_variables << EnvironmentVariable.create(key: 'database_password', value: 'supersecretpasswordwow') -bridge.headers << Header.create(key: 'X_API_KEY', value: 'ooosecrets') -bridge.headers << Header.create(key: 'Authentication', value: 'Bearer &&&&&&&&&&&&&&&&') +bridge.headers << Header.create(key: 'SHOULD_BE_FILTERED', value: '$env.DATABASE_URL') +bridge.headers << Header.create(key: 'not_filtered', value: 'bridge api') bridge2 = Bridge.create( - user: user2, + user_id: user2.id, title: 'My Second Bridge', + inbound_url: 'bridgeapi.com/746353', outbound_url: test_url, - method: 'PATCH', - retries: 0, + http_method: 'PATCH', + retries: 3, delay: 0, - data: { payload: '{}', test_payload: '{}' } + data: { + payload: '{"FirstName":"Booths","LastName":"John","UserName":"FordTheatre","Password":{"nested":"sic temper tyrannis"},"Email":"mail@mail.com"}', + test_payload: '{"test_key_one":{"nested":11},"test_key_two":888}' + } ) bridge2.environment_variables << EnvironmentVariable.create(key: 'database', value: 'z9992374623') @@ -38,4 +55,4 @@ def test_url 5.times do bridge.events.create(completed: false, outbound_url: 'ip.jsontest.com', inbound_url: 'ip.jsontest.com', data: '{"inbound":{"payload":{"FirstName":"Lee","LastName":"Oswald","UserName":"GrassyKnoll","Password":{"nested":"magic bullet"},"Email":"kgb63@yandex.ru"},"dateTime":"2020-11-20T02:02:55.745Z","ip":"::1","contentLength":152},"outbound":[{"request":{"payload":{"FirstName":"Lee","LastName":"Oswald","UserName":"GrassyKnoll","Password":{"nested":"magic bullet"},"Email":"kgb63@yandex.ru"},"dateTime":"2020-11-20T02:02:55.832Z","contentLength":7},"response":{"dateTime":"2020-11-20T02:02:55.882Z","statusCode":"200","message":"OK","size":7,"payload":{"ip":"153.33.111.24"}}}]}', status_code: 300) bridge2.events.create(completed: false, outbound_url: 'ip.jsontest.com', inbound_url: 'ip.jsontest.com', data: '{"inbound":{"payload":{"FirstName":"Billy","LastName":"Bob","UserName":"Hitman","Password":{"nested":"badaboom"},"Email":"agag@av.ru"},"dateTime":"2020-10-25T02:10:55.745Z","ip":"::2","contentLength":152},"outbound":[{"request":{"payload":{"FirstName":"Lee","LastName":"Oswald","UserName":"GrassyKnoll","Password":{"nested":"magic bullet"},"Email":"kgb63@yandex.ru"},"dateTime":"2020-11-20T02:02:55.832Z","contentLength":7},"response":{"dateTime":"2020-11-20T02:02:55.882Z","statusCode":"200","message":"OK","size":7,"payload":{"ip":"153.33.111.24"}}}]}', status_code: 302) -end \ No newline at end of file +end diff --git a/dump.rdb b/dump.rdb index 6982b67..7f3083b 100644 Binary files a/dump.rdb and b/dump.rdb differ diff --git a/spec/factories/bridge_factory.rb b/spec/factories/bridge_factory.rb new file mode 100644 index 0000000..9414bfa --- /dev/null +++ b/spec/factories/bridge_factory.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :bridge do + title { 'bridge_1' } + outbound_url { 'myfakeoutbound.com' } + inbound_url { 'myfakeinbound.com' } + http_method { 'POST' } + delay { 0 } + retries { 0 } + data do + { + payload: { + first_name: 'Lee', + last_name: 'Oswald', + username: 'GrassyKnoll', + email: 'kgb63@yandex.ru', + top_level_key: '$payload.top_level_key', + nested_key: { + nested_key_two: '$payload.nested_key_1.nested_key_2' + } + }.to_json, + test_payload: { + "test_key_one": { + "nested": 11 + }, + "test_key_two": 888 + }.to_json + } + end + + association :user + + trait :with_env do + after(:create) do |bridge| + create(:environment_variable, bridge_id: bridge.id) + end + end + + trait :with_header do + after(:create) do |bridge| + create(:header, bridge_id: bridge.id) + end + end + end +end diff --git a/spec/factories/environment_variable_factory.rb b/spec/factories/environment_variable_factory.rb new file mode 100644 index 0000000..8034c62 --- /dev/null +++ b/spec/factories/environment_variable_factory.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :environment_variable do + key { 'API_KEY' } + value { 'hello world' } + end +end diff --git a/spec/factories/event_factory.rb b/spec/factories/event_factory.rb new file mode 100644 index 0000000..6273d92 --- /dev/null +++ b/spec/factories/event_factory.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# TODO? +FactoryBot.define do + factory :event do + association :bridge + + completed { false } + inbound_url { 'myfakeinbound.com' } + outbound_url { 'myfakeoutbound.com' } + data do + { + 'inbound' => { + 'payload' => { + 'bridge_id' => '1', + 'top_level_key' => 'present', + 'nested_key_1' => { + 'nested_key_2' => 'present' + } + }, + 'dateTime' => '2020-11-21T13:59:47.349Z', + 'ip' => '0.0.0.0', + 'contentLength' => 101 + }, + 'outbound' => [] + }.to_json + end + + trait :completed do + # TODO + end + end +end diff --git a/spec/factories/header_factory.rb b/spec/factories/header_factory.rb new file mode 100644 index 0000000..f315ff1 --- /dev/null +++ b/spec/factories/header_factory.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :header do + key { 'hello' } + value { '$env.API_KEY' } + end +end diff --git a/spec/factories/user_factory.rb b/spec/factories/user_factory.rb new file mode 100644 index 0000000..66c3f10 --- /dev/null +++ b/spec/factories/user_factory.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :user do + email { 'demo@demo.com' } + password { 'password' } + end +end diff --git a/spec/lib/bridge_api/http/builder_spec.rb b/spec/lib/bridge_api/http/builder_spec.rb new file mode 100644 index 0000000..a2b6ae7 --- /dev/null +++ b/spec/lib/bridge_api/http/builder_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative './spec_helper' + +RSpec.describe BridgeApi::Http::Builder do + subject do + event = create(:event) + BridgeApi::Http::Builder.new( + MockRequestHandler.new(event), + MockPayloadParser.new, + MockHeadersParser.new + ) + end + + it 'can generate http objects' do + http, req = subject.generate + + expect(http.use_ssl?).to be true + expect(http.port).to eq 443 + + expect(req['Content-Type']).to eq 'application/json' + expect(req['header_1']).to eq 'value_1' + expect(req['header_2']).to eq 'value_2' + expect(req.uri.to_s).to eq 'https://myfakeoutbound.com' + expect(req.body).to eq '{"data":"hello world"}' + end +end diff --git a/spec/lib/bridge_api/http/deconstructor_spec.rb b/spec/lib/bridge_api/http/deconstructor_spec.rb new file mode 100644 index 0000000..60c8c12 --- /dev/null +++ b/spec/lib/bridge_api/http/deconstructor_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative './spec_helper' + +RSpec.describe BridgeApi::Http::Deconstructor do + subject do + bridge = create(:bridge, :with_header) + BridgeApi::Http::Deconstructor.new bridge.headers + end + + it 'can return filtered headers' do + expect(subject.deconstruct('hello', 'world')).to eq 'FILTERED' + end + + it 'can return the value when header doesn\'t exist' do + expect(subject.deconstruct('hi', 'world')).to eq 'world' + end +end diff --git a/spec/lib/bridge_api/http/formatter_spec.rb b/spec/lib/bridge_api/http/formatter_spec.rb new file mode 100644 index 0000000..fddbe66 --- /dev/null +++ b/spec/lib/bridge_api/http/formatter_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative './spec_helper' + +RSpec.describe BridgeApi::Http::Formatter do + before do + stub_request(:post, 'http://example.com/some_path?query=string') + .with(headers: { 'Accept' => '*/*', 'User-Agent' => 'Ruby' }) + .to_return(status: 200, body: { data: 'stubbed response' }.to_json, headers: {}) + end + + subject do + BridgeApi::Http::Formatter.new MockDeconstructor.new + end + + it 'can format a successful request' do + event = create(:event) + uri = URI('http://example.com/some_path?query=string') + + Net::HTTP.start(uri.host, uri.port) do |http| + request = Net::HTTP::Post.new uri + request.body = { data: 'hello world' }.to_json + response = http.request request # Net::HTTPResponse object + subject.format! event, request, response + end + + data = JSON.parse(event.data) + + expect(data.keys).to eq %w[inbound outbound] + expect(data['outbound'].first.keys).to eq %w[request response] + expect(data['outbound'].first['request'].keys).to eq %w[payload dateTime contentLength uri headers] + expect(data['outbound'].first['response'].keys).to eq %w[dateTime statusCode message size payload] + expect(data['outbound'].first['request']['payload']).to be_truthy + expect(data['outbound'].first['response']['payload']).to be_truthy + expect(data['outbound'].first['response']['statusCode']).to eq '200' + expect(data['outbound'].first['request']['uri']).to eq 'http://example.com/some_path?query=string' + end + + it 'can format a failed request' do + event = create(:event) + uri = URI('http://example2.com/some_path?query=string') + + Net::HTTP.start(uri.host, uri.port) do |_http| + request = Net::HTTP::Post.new uri + request.body = { data: 'hello world' }.to_json + raise StandardError + rescue StandardError => e + subject.format_error! event, request, e + end + + data = JSON.parse(event.data) + + expect(data.keys).to eq %w[inbound outbound] + expect(data['outbound'].first.keys).to eq %w[request response] + expect(data['outbound'].first['request'].keys).to eq %w[payload dateTime contentLength uri headers] + expect(data['outbound'].first['response'].keys).to eq %w[message] + expect(data['outbound'].first['request']['payload']).to be_truthy + expect(data['outbound'].first['request']['uri']).to eq 'http://example2.com/some_path?query=string' + expect(data['outbound'].first['response']).to eq({ 'message' => 'StandardError' }) + end +end diff --git a/spec/lib/bridge_api/http/request_handler_spec.rb b/spec/lib/bridge_api/http/request_handler_spec.rb new file mode 100644 index 0000000..971a590 --- /dev/null +++ b/spec/lib/bridge_api/http/request_handler_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative './spec_helper' + +RSpec.describe BridgeApi::Http::Deconstructor do + before do + stub_request(:post, 'https://myfakeoutbound.com:80/') + .with(headers: { 'Accept' => '*/*', 'User-Agent' => 'Ruby' }) + .to_return(status: 200, body: { data: 'stubbed response' }.to_json, headers: {}) + + stub_request(:post, 'https://myfakeoutbound2.com:80/') + .with(headers: { 'Accept' => '*/*', 'User-Agent' => 'Ruby' }) + .to_return(status: 300, body: { data: 'stubbed response' }.to_json, headers: {}) + end + + subject do + event = create(:event) + handler = BridgeApi::Http::RequestHandler.new event + handler.http_builder = MockSuccessBuilder.new + handler.formatter = MockFormatter.new + handler + end + + it 'can execute' do + subject.execute + end + + it 'can cleanup' do + subject.cleanup(StandardError) + end + + it 'raises an error for non-200 status codes' do + subject.http_builder = MockFailBuilder.new + expect do + subject.execute + end.to raise_error Sidekiq::LargeStatusCode + end +end diff --git a/spec/lib/bridge_api/http/spec_helper.rb b/spec/lib/bridge_api/http/spec_helper.rb new file mode 100644 index 0000000..49a19bd --- /dev/null +++ b/spec/lib/bridge_api/http/spec_helper.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'rails_helper' + +require 'webmock/rspec' +WebMock.disable_net_connect!(allow_localhost: true) + +require_relative '../../../support/lib/bridge_api/http/mock_deconstructor' +require_relative '../../../support/lib/bridge_api/http/mock_builders' +require_relative '../../../support/lib/bridge_api/http/mock_formatter' +require_relative '../../../support/lib/bridge_api/http/mock_request_handler' + +require_relative '../../../support/lib/bridge_api/syntax_parser/mock_headers_parser' +require_relative '../../../support/lib/bridge_api/syntax_parser/mock_payload_parser' diff --git a/spec/lib/bridge_api/syntax_parser/headers_parser_spec.rb b/spec/lib/bridge_api/syntax_parser/headers_parser_spec.rb new file mode 100644 index 0000000..0df055f --- /dev/null +++ b/spec/lib/bridge_api/syntax_parser/headers_parser_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe BridgeApi::SyntaxParser::HeadersParser do + it 'can parse envs' do + bridge = create(:bridge, :with_env, :with_header) + parser = BridgeApi::SyntaxParser::HeadersParser.new(bridge.environment_variables) + data = {} + + parser.parse(bridge.headers) do |k, v| + data[k] = v + end + + expect(data).to eq({ 'hello' => 'hello world' }) + end + + it 'will raise InvalidEnvironmentVariable when no key exists' do + bridge = create(:bridge, :with_header) + parser = BridgeApi::SyntaxParser::HeadersParser.new(bridge.environment_variables) + data = {} + + expect do + parser.parse(bridge.headers) do |k, v| + data[k] = v + end + end.to raise_error InvalidEnvironmentVariable + end +end diff --git a/spec/lib/bridge_api/syntax_parser/payload_parser_spec.rb b/spec/lib/bridge_api/syntax_parser/payload_parser_spec.rb new file mode 100644 index 0000000..34959b1 --- /dev/null +++ b/spec/lib/bridge_api/syntax_parser/payload_parser_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe BridgeApi::SyntaxParser::PayloadParser do + subject do + bridge = create(:bridge, :with_env) + BridgeApi::SyntaxParser::PayloadParser.new(bridge.environment_variables) + end + + it 'can parse envs' do + expect( + subject.parse( + Net::HTTP.new(URI('example.com')), + { data: '$env.API_KEY' } + ) + ).to eq({ data: 'hello world' }) + end + + it 'will raise InvalidEnvironmentVariable when no key exists' do + expect do + subject.parse( + Net::HTTP.new(URI('example.com')), + { data: '$env.api_key' } + ) + end.to raise_error InvalidEnvironmentVariable + end + + it 'can access payload data wtih symbols' do + expect( + subject.parse( + { data: 'hello world' }, + { data: '$payload.data' } + ) + ).to eq({ data: 'hello world' }) + end + + it 'can access payload data wtih strings' do + expect( + subject.parse( + { 'data' => 'hello world' }, + { 'data' => '$payload.data' } + ) + ).to eq({ 'data' => 'hello world' }) + end + + it 'can access deeply nested objects' do + expect( + subject.parse( + { + 'data' => 'hello world', + 'nested_data' => { + 'nested_data_1' => [ + { + 'nested_data_2' => 'hello world2' + } + ] + } + }, + { + 'data' => { + 'nested_key' => '$payload.data', + 'nested_array' => [ + { + 'one' => '$payload.nested_data.nested_data_1' + }, + { + 'two' => '$payload.nested_data.nested_data_1' + } + ] + } + } + ) + ).to eq( + { + 'data' => { + 'nested_array' => [ + { + 'one' => [ + { + 'nested_data_2' => 'hello world2' + } + ] + }, + { + 'two' => [ + { + 'nested_data_2' => 'hello world2' + } + ] + } + ], + 'nested_key' => 'hello world' + } + } + ) + end +end diff --git a/spec/models/bridge_spec.rb b/spec/models/bridge_spec.rb index 96768ee..c55c2ba 100644 --- a/spec/models/bridge_spec.rb +++ b/spec/models/bridge_spec.rb @@ -30,7 +30,7 @@ end it 'is invalid without a method' do - subject.method = nil + subject.http_method = nil expect(subject).to_not be_valid end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 86f63dd..00744b2 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -3,33 +3,66 @@ require 'rails_helper' RSpec.describe Event, type: :model do - before do + before(:context) do create_user end subject do - create_bridge - end - - it 'belongs to bridge' do - event1 = Event.create( - completed: false, - outbound_url: subject.outbound_url, - inbound_url: subject.inbound_url, - data: '', - status_code: 300 - ) - event2 = Event.create( - completed: false, - outbound_url: subject.outbound_url, - inbound_url: subject.inbound_url, - data: '', - status_code: 300 - ) - - subject.events << event1 - subject.events << event2 - expect(event1.bridge).to eq subject - expect(event2.bridge).to eq subject + create_event end + + it 'is valid as long as long as it has a valid bridge_id and data object' do + event = Event.create({ bridge_id: subject.bridge_id, data: subject.data }) + expect(event).to be_valid + end + + it 'is invalid without a data object' do + subject.data = nil + expect(subject).to_not be_valid + end + + it 'belongs to a bridge' do + expect(subject.bridge.class).to eql Bridge + end + + it 'has its urls set to the url of the bridge it belongs to' do + bridge = subject.bridge + expect(bridge.inbound_url).to eql subject.inbound_url + expect(bridge.outbound_url).to eql subject.outbound_url + end + + it 'has a data attribute referencing a json object' do + expect(JSON.parse(subject.data)).to be_an_instance_of(Hash) + end + + it 'raises an error if data attribute does not reference a JSON object' do + subject.data = 'not a json object' + expect { subject.save! }.to raise_error('Validation failed: Data object must be a valid json object') + end + + it 'does not accept removing inbound or outbound key from json data object' do + data = JSON.parse(subject.data) + data.delete('outbound') + subject.data = data.to_json + expect { subject.save! }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'does not accept removing payload, date, time, ip or content length from inbound key in json data object' do + data = JSON.parse(subject.data) + data['inbound'].delete('payload') + subject.data = data.to_json + expect { subject.save! }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'does not accept status codes above 599' do + subject.status_code = 5555 + expect { subject.save! }.to raise_error('Validation failed: Status code must be less than or equal to 599') + end + + it 'does not accept status codes below 100' do + subject.status_code = 10 + expect { subject.save! }.to raise_error('Validation failed: Status code must be greater than or equal to 100') + end + + pending '#inbound_payload returns the proper data' end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 191f3b9..d508aae 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -4,11 +4,14 @@ require 'spec_helper' ENV['RAILS_ENV'] ||= 'test' require File.expand_path('../config/environment', __dir__) + # Prevent database truncation if the environment is production abort('The Rails environment is running in production mode!') if Rails.env.production? require 'rspec/rails' + # Add additional requires below this line. Rails is not loaded until this point! require './spec/support/main_helper' +require './spec/support/factory_bot' # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are # run as spec files by default. This means that files in spec/support that end diff --git a/spec/requests/bridges_request_spec.rb b/spec/requests/bridges_request_spec.rb index e7b5d35..7236f43 100644 --- a/spec/requests/bridges_request_spec.rb +++ b/spec/requests/bridges_request_spec.rb @@ -133,7 +133,7 @@ end it 'doesnt create bridge without method' do invalid_hash = bridge_hash - invalid_hash[:method] = nil + invalid_hash[:http_method] = nil post bridges_path, params: { bridge: invalid_hash }, headers: authenticated_token diff --git a/spec/requests/events_request_spec.rb b/spec/requests/events_request_spec.rb new file mode 100644 index 0000000..e5a19eb --- /dev/null +++ b/spec/requests/events_request_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'EventsController', type: :request do + before do + @event = create(:event) + @bridge = @event.bridge + @user = @bridge.user + @token = JsonWebToken.encode(user_id: @user.id) + end + + describe 'GET index' do + it 'returns 200 with bridge_id' do + get '/events', headers: authenticated_token, params: { bridge_id: @bridge.id } + expect(response).to have_http_status(:ok) + end + + it 'returns 200 with event_id' do + get '/events', headers: authenticated_token, params: { event_id: @event.id } + expect(response).to have_http_status(:ok) + end + + it 'returns 400 with no params' do + get '/events', headers: authenticated_token + expect(response).to have_http_status(400) + end + + it 'returns 400 with invalid IDs' do + get '/events', headers: authenticated_token, params: { event_id: '128371283' } + expect(response).to have_http_status(400) + + get '/events', headers: authenticated_token, params: { bridge_id: '128371283' } + expect(response).to have_http_status(400) + end + + it 'requires JWT' do + get '/events', params: { event_id: @event.id } + expect(response).to have_http_status(401) + end + end + + describe 'GET show' do + it 'returns 200 with bridge_id' do + get "/events/#{@event.id}", headers: authenticated_token, params: { event_id: @event.id } + expect(response).to have_http_status(:ok) + end + + it 'returns 400 with invalid IDs' do + get '/events/128371283', headers: authenticated_token, params: { event_id: '128371283' } + expect(response).to have_http_status(400) + + get '/events/128371283', headers: authenticated_token, params: { bridge_id: '128371283' } + expect(response).to have_http_status(400) + end + + it 'requires JWT' do + get "/events/#{@event.id}", params: { event_id: @event.id } + expect(response).to have_http_status(401) + end + end + + describe 'POST destroy' do + it 'returns 204' do + delete "/events/#{@event.id}", headers: authenticated_token, params: { event_id: @event.id } + expect(response).to have_http_status(204) + end + + it 'returns 400 with invalid IDs' do + delete '/events/128371283', headers: authenticated_token, params: { event_id: '128371283' } + expect(response).to have_http_status(400) + + delete '/events/128371283', headers: authenticated_token, params: { bridge_id: '128371283' } + expect(response).to have_http_status(400) + end + + it 'requires JWT' do + delete "/events/#{@event.id}", params: { event_id: @event.id } + expect(response).to have_http_status(401) + end + end + + describe 'POST create' do + pending 'creates a job' + + it 'returns 400 with invalid IDs' do + headers = { 'CONTENT_TYPE' => 'application/json' } + post '/events/128371283', params: '{ "data": { "hello": "world" } }', headers: headers + expect(response).to have_http_status(400) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 01f7c97..7e10955 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -93,4 +93,7 @@ # # test failures related to randomization by passing the same `--seed` value # # as the one that triggered the failure. # Kernel.srand config.seed + + # TODO: Maybe + # config.use_transactional_fixtures = true end diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb new file mode 100644 index 0000000..2e7665c --- /dev/null +++ b/spec/support/factory_bot.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods +end diff --git a/spec/support/lib/bridge_api/http/mock_builders.rb b/spec/support/lib/bridge_api/http/mock_builders.rb new file mode 100644 index 0000000..5911b16 --- /dev/null +++ b/spec/support/lib/bridge_api/http/mock_builders.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class MockSuccessBuilder + def generate + http = Net::HTTP.new(URI('myfakeoutbound.com')) + http.use_ssl = true + [ + http, + Net::HTTP::Post.new(URI('https://myfakeoutbound.com'), 'Content-Type' => 'application/json') + ] + end +end + +class MockFailBuilder + def generate + http = Net::HTTP.new(URI('myfakeoutbound2.com')) + http.use_ssl = true + [ + http, + Net::HTTP::Post.new(URI('https://myfakeoutbound2.com'), 'Content-Type' => 'application/json') + ] + end +end diff --git a/spec/support/lib/bridge_api/http/mock_deconstructor.rb b/spec/support/lib/bridge_api/http/mock_deconstructor.rb new file mode 100644 index 0000000..509c147 --- /dev/null +++ b/spec/support/lib/bridge_api/http/mock_deconstructor.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class MockDeconstructor + def deconstruct(_key, value) + value + end +end diff --git a/spec/support/lib/bridge_api/http/mock_formatter.rb b/spec/support/lib/bridge_api/http/mock_formatter.rb new file mode 100644 index 0000000..2602651 --- /dev/null +++ b/spec/support/lib/bridge_api/http/mock_formatter.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class MockFormatter + def format!(_event, _req, _res) + true + end + + def format_error!(_event, _req, _res) + true + end +end + +class MockFailFormatter + def format!(_event, _req, _res) + raise StandardError + end + + def cleanup(_event, _request, _error) + true + end +end diff --git a/spec/support/lib/bridge_api/http/mock_request_handler.rb b/spec/support/lib/bridge_api/http/mock_request_handler.rb new file mode 100644 index 0000000..e171fbc --- /dev/null +++ b/spec/support/lib/bridge_api/http/mock_request_handler.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class MockRequestHandler + attr_reader :event, :bridge + + def initialize(event) + @event = event + @bridge = event.bridge + end +end diff --git a/spec/support/lib/bridge_api/syntax_parser/mock_headers_parser.rb b/spec/support/lib/bridge_api/syntax_parser/mock_headers_parser.rb new file mode 100644 index 0000000..9c8f9fd --- /dev/null +++ b/spec/support/lib/bridge_api/syntax_parser/mock_headers_parser.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class MockHeadersParser + def parse(_headers, &block) + [ + { + key: 'header_1', + value: 'value_1' + }, + { + key: 'header_2', + value: 'value_2' + } + ].each { |header| block.call header[:key], header[:value] } + end +end diff --git a/spec/support/lib/bridge_api/syntax_parser/mock_payload_parser.rb b/spec/support/lib/bridge_api/syntax_parser/mock_payload_parser.rb new file mode 100644 index 0000000..5c86658 --- /dev/null +++ b/spec/support/lib/bridge_api/syntax_parser/mock_payload_parser.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class MockPayloadParser + def parse(_payload, _user_data) + { data: 'hello world' } + end +end diff --git a/spec/support/main_helper.rb b/spec/support/main_helper.rb index 98a45ce..468404d 100644 --- a/spec/support/main_helper.rb +++ b/spec/support/main_helper.rb @@ -1,8 +1,15 @@ # frozen_string_literal: true +def random_email + local = [] + 8.times { |_| local.push(('a'..'z').to_a.sample) } + domain = ['aol.com', 'nsa.gov', 'jubii.dk', 'kreml.ru', 'hotmail.com', 'netscape.com'][rand(6)] + "#{local.join}@#{domain}" +end + module MainHelper def create_user - @current_user = User.create(email: 'admin@bridge.io', password: 'password', notifications: false) + @current_user = User.create(email: random_email, password: 'password', notifications: false) @token = JsonWebToken.encode(user_id: @current_user.id) end @@ -19,7 +26,7 @@ def bridge_hash user: @current_user, title: 'bridge', outbound_url: "doggoapi.io/#{(String(rand).split '.')[1]}", - method: 'POST', + http_method: 'POST', retries: 5, delay: 15, data: { payload: '{}', test_payload: '{}' } @@ -31,4 +38,61 @@ def create_bridge **bridge_hash ) end + + # rubocop:disable Metrics/MethodLength + def event_data + { + 'inbound' => { + 'payload' => { + 'FirstName' => 'Lee', + 'LastName' => 'Oswald', + 'UserName' => 'GrassyKnoll', + 'Password' => { 'nested' => 'magic bullet' }, + 'Email' => 'kgb63@yandex.ru' + }, + 'dateTime' => '2020-11-17', + 'ip' => '::1', + 'contentLength' => '152', + 'headers' => [] + }, + 'outbound' => [ + { 'request' => { + 'payload' => { + 'FirstName' => 'Lee', + 'LastName' => 'Oswald', + 'UserName' => 'GrassyKnoll', + 'Password' => { 'nested' => 'magic bullet' }, + 'Email' => 'kgb63@yandex.ru' + }, + 'dateTime' => '2020-11-17', + 'contentLength' => '7' + }, + 'response' => { + 'date' => '2020-11-17', + 'time' => '03:23:35', + 'status_code' => '200', + 'message' => 'OK', + 'size' => '7', + 'payload' => { 'ip' => '153.33.111.24' } + } } + ] + }.to_json + end + # rubocop:enable Metrics/MethodLength + + def create_event + @bridge = create_bridge + @bridge.save + @event = Event.create({ + bridge_id: @bridge.id, + data: event_data, + status_code: 200, + completed: true, + completed_at: Time.now.utc + 30 + }) + end + + def destroy_event + @event.destroy! + end end diff --git a/spec/workers/event_worker_spec.rb b/spec/workers/event_worker_spec.rb new file mode 100644 index 0000000..15c0052 --- /dev/null +++ b/spec/workers/event_worker_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative './spec_helper' + +RSpec.describe EventWorker, type: :worker do + # it 'Event jobs are enqueued in the scheduled queue' do + # described_class.perform_async + # assert_equal :scheduled, described_class.queue + # end + + # it 'goes into the jobs array for testing environment' do + # expect do + # described_class.perform_async + # end.to change(described_class.jobs, :size).by(1) + # end + + pending 'can process an event' + pending 'can retry 3 times' + pending 'can clean up when errors are raised' +end diff --git a/spec/workers/spec_helper.rb b/spec/workers/spec_helper.rb new file mode 100644 index 0000000..b55e6a5 --- /dev/null +++ b/spec/workers/spec_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'sidekiq/testing' + +Sidekiq::Testing.fake! + +# TODO +# RSpec::Sidekiq.configure do |config| +# # Clears all job queues before each example +# config.clear_all_enqueued_jobs = true # default => true +# # Whether to use terminal colours when outputting messages +# config.enable_terminal_colours = true # default => true +# # Warn when jobs are not enqueued to Redis but to a job array +# config.warn_when_jobs_not_processed_by_sidekiq = true # default => true +# end