diff --git a/app/jobs/arcgis_token_job.rb b/app/jobs/arcgis_token_job.rb new file mode 100644 index 00000000000..a102a37044f --- /dev/null +++ b/app/jobs/arcgis_token_job.rb @@ -0,0 +1,26 @@ +class ArcgisTokenJob < ApplicationJob + queue_as :default + + def perform + analytics.idv_arcgis_token_job_started + token_keeper.refresh_token + return true + ensure + analytics.idv_arcgis_token_job_completed + end + + private + + def token_keeper + ArcgisApi::TokenKeeper.new + end + + def analytics + @analytics ||= Analytics.new( + user: AnonymousUser.new, + request: nil, + session: {}, + sp: nil, + ) + end +end diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 700b81b84b0..39c34986a44 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -559,6 +559,56 @@ def idv_arcgis_request_failure( ) end + # Tracks if request to get auth token from ArcGIS fails + # @param [String] exception_class + # @param [String] exception_message + # @param [Boolean] response_body_present + # @param [Hash] response_body + # @param [Integer] response_status_code + # @param [Integer,nil] retry_count + # @param [Integer,nil] retry_max + # @param [Float,nil] will_retry_in + def idv_arcgis_token_failure( + exception_class:, + exception_message:, + response_body_present:, + response_body:, + response_status_code:, + retry_count: nil, + retry_max: nil, + will_retry_in: nil, + **extra + ) + track_event( + 'Request ArcGIS Token: request failed', + exception_class: exception_class, + exception_message: exception_message, + response_body_present: response_body_present, + response_body: response_body, + response_status_code: response_status_code, + retry_count: retry_count, + retry_max: retry_max, + will_retry_in: will_retry_in, + **extra, + ) + end + + # Track when ArcGIS auth token refresh job completed + def idv_arcgis_token_job_completed(**extra) + track_event( + 'ArcgisTokenJob: Completed', + **extra, + ) + end + + # Track when ArcGIS auth token refresh job started + def idv_arcgis_token_job_started(**extra) + track_event( + 'ArcgisTokenJob: Started', + **extra, + ) + end + # @param [String] step the step that the user was on when they clicked cancel # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components # The user confirmed their choice to cancel going through IDV diff --git a/app/services/arcgis_api/auth/authentication.rb b/app/services/arcgis_api/auth/authentication.rb new file mode 100644 index 00000000000..42a213b9af2 --- /dev/null +++ b/app/services/arcgis_api/auth/authentication.rb @@ -0,0 +1,97 @@ +module ArcgisApi::Auth + # Authenticate with the ArcGIS API + class Authentication + def initialize(analytics: nil) + @analytics = analytics || Analytics.new( + user: AnonymousUser.new, + request: nil, + session: {}, + sp: nil, + ) + end + + # Makes a request to retrieve a new token + # it expires after 1 hour + # @return [ArcgisApi::Auth::Token] Auth token + def retrieve_token + token, expires = request_token.fetch_values('token', 'expires') + expires_at = Time.zone.at(expires / 1000).to_f + return ArcgisApi::Auth::Token.new(token: token, expires_at: expires_at) + end + + private + + # Makes HTTP request to authentication endpoint and + # returns the token and when it expires (1 hour). + # @return [Hash] API response + def request_token + body = { + username: arcgis_api_username, + password: arcgis_api_password, + referer: domain_name, + f: 'json', + } + + connection.post( + arcgis_api_generate_token_url, URI.encode_www_form(body) + ) do |req| + req.options.context = { service_name: 'arcgis_token' } + end.body + end + + def connection + ::Faraday.new do |conn| + ArcgisApi::Faraday::Configuration.setup(conn) + ArcgisApi::Faraday::Configuration.add_retry(conn) do |**args| + log_retry(**args) + end + yield conn if block_given? + end + end + + # @param [Faraday::Env] env Request environment + # @param [Faraday::Options] options middleware options + # @param [Integer] retry_count how many retries have already occured (starts at 0) + # @param [Exception] exception exception that triggered the retry, + # will be the synthetic `Faraday::RetriableResponse` if the + # retry was triggered by something other than an exception. + # @param [Float] will_retry_in retry_block is called *before* the retry + # delay, actual retry will happen in will_retry_in number of + # seconds. + def log_retry(env:, options:, retry_count:, exception:, will_retry_in:) + resp_body = env.body.then do |body| + if body.is_a?(String) + JSON.parse(body) + else + body + end + rescue + body + end + + http_status = env.status + api_status_code = resp_body.is_a?(Hash) ? resp_body.dig('error', 'code') : http_status + analytics.idv_arcgis_token_failure( + exception_class: exception.class.name, + exception_message: exception.message, + response_body_present: resp_body.present?, + response_body: resp_body, + response_status_code: http_status, + api_status_code: api_status_code, + + # Include retry-specific data + retry_count:, + retry_max: options.max, + will_retry_in:, + ) + end + + attr_accessor :analytics + + delegate :arcgis_api_username, + :arcgis_api_password, + :domain_name, + :arcgis_api_generate_token_url, + to: :"IdentityConfig.store" + end +end diff --git a/app/services/arcgis_api/auth/cache/raw_token_cache.rb b/app/services/arcgis_api/auth/cache/raw_token_cache.rb new file mode 100644 index 00000000000..1f2ae6ad934 --- /dev/null +++ b/app/services/arcgis_api/auth/cache/raw_token_cache.rb @@ -0,0 +1,34 @@ +module ArcgisApi::Auth::Cache + class RawTokenCache + # @param [ActiveSupport::Cache::Store] cache + # @param [String] cache_key + def initialize(cache: nil, cache_key: nil) + @cache = cache || Rails.cache + @cache_key = cache_key || default_api_token_cache_key + end + + # @return [Object,nil] Cached auth token + def token + raw_token = cache.read(cache_key) + raw_token || nil + end + + # @param [Object,nil] cache_value the value to write to cache + # @param [Number] expires_at the hard expiration time in unix time (milliseconds) + def save_token(cache_value, expires_at) + cache.write(cache_key, cache_value, expires_at) + end + + private + + def default_api_token_cache_key + "#{arcgis_api_token_cache_key_prefix}:#{URI(arcgis_api_generate_token_url).host}" + end + + delegate :arcgis_api_token_cache_key_prefix, + :arcgis_api_generate_token_url, + to: :"IdentityConfig.store" + + attr_accessor :cache, :cache_key + end +end diff --git a/app/services/arcgis_api/auth/cache/token_cache_info_writer.rb b/app/services/arcgis_api/auth/cache/token_cache_info_writer.rb new file mode 100644 index 00000000000..944b5a32141 --- /dev/null +++ b/app/services/arcgis_api/auth/cache/token_cache_info_writer.rb @@ -0,0 +1,8 @@ +module ArcgisApi::Auth::Cache + class TokenCacheInfoWriter < TokenCacheWriter + # @param [ArcgisApi::Auth::Token] cache_value the value to write to cache + def save_token(cache_value) + token_cache.save_token(cache_value, expires_at: cache_value.expires_at) + end + end +end diff --git a/app/services/arcgis_api/auth/cache/token_cache_raw_writer.rb b/app/services/arcgis_api/auth/cache/token_cache_raw_writer.rb new file mode 100644 index 00000000000..a289c45ad26 --- /dev/null +++ b/app/services/arcgis_api/auth/cache/token_cache_raw_writer.rb @@ -0,0 +1,8 @@ +module ArcgisApi::Auth::Cache + class TokenCacheRawWriter < TokenCacheWriter + # @param [ArcgisApi::Auth::Token] cache_value the value to write to cache + def save_token(cache_value) + token_cache.save_token(cache_value.token, expires_at: cache_value.expires_at) + end + end +end diff --git a/app/services/arcgis_api/auth/cache/token_cache_reader.rb b/app/services/arcgis_api/auth/cache/token_cache_reader.rb new file mode 100644 index 00000000000..1506e5ff570 --- /dev/null +++ b/app/services/arcgis_api/auth/cache/token_cache_reader.rb @@ -0,0 +1,31 @@ +module ArcgisApi::Auth::Cache + class TokenCacheReader + # @param [ArcgisApi::Auth::Cache::RawTokenCache] token_cache + def initialize(token_cache: nil) + @token_cache = token_cache || ArcgisApi::Auth::Cache::RawTokenCache.new + end + + # @return [String,nil] auth token + def token + token_entry&.token + end + + # Fetch, wrap, and return cache entry for ArcGIS API token + # @return [ArcgisApi::Auth::Token,nil] token, or nil if not present in cache + def token_entry + cache_entry = token_cache.token + + if cache_entry.is_a?(String) + ArcgisApi::Auth::Token.new( + token: cache_entry, + ) + elsif cache_entry.is_a?(ArcgisApi::Auth::Token) + cache_entry + end + end + + protected + + attr_accessor :token_cache + end +end diff --git a/app/services/arcgis_api/auth/cache/token_cache_writer.rb b/app/services/arcgis_api/auth/cache/token_cache_writer.rb new file mode 100644 index 00000000000..9517c0c4174 --- /dev/null +++ b/app/services/arcgis_api/auth/cache/token_cache_writer.rb @@ -0,0 +1,6 @@ +module ArcgisApi::Auth::Cache + class TokenCacheWriter < TokenCacheReader + # @param [ArcgisApi::Auth::Token] cache_value the value to write to cache + def save_token(cache_value); end + end +end diff --git a/app/services/arcgis_api/auth/refresh/always_refresh_strategy.rb b/app/services/arcgis_api/auth/refresh/always_refresh_strategy.rb new file mode 100644 index 00000000000..2e1bc7717a1 --- /dev/null +++ b/app/services/arcgis_api/auth/refresh/always_refresh_strategy.rb @@ -0,0 +1,13 @@ +module ArcgisApi::Auth::Refresh + # Always refreshes the token + class AlwaysRefreshStrategy < RefreshStrategy + # @param [ArcgisApi::Auth::Authentication] auth + # @param [ArcgisApi::Auth::Cache::TokenCacheWriter] cache + # @return [ArcgisApi::Auth::Token] + def call(auth:, cache:) + token_entry = auth.retrieve_token + cache.save_token(token_entry) + token_entry + end + end +end diff --git a/app/services/arcgis_api/auth/refresh/fetch_without_refresh_strategy.rb b/app/services/arcgis_api/auth/refresh/fetch_without_refresh_strategy.rb new file mode 100644 index 00000000000..7d9028e5ed3 --- /dev/null +++ b/app/services/arcgis_api/auth/refresh/fetch_without_refresh_strategy.rb @@ -0,0 +1,13 @@ +module ArcgisApi::Auth::Refresh + # Does not attempt to refresh a token when it's expired + class FetchWithoutRefreshStrategy < RefreshStrategy + # rubocop:disable Lint/UnusedMethodArgument + # @param [ArcgisApi::Auth::Authentication] auth + # @param [ArcgisApi::Auth::Cache::TokenCacheWriter] cache + # @return [ArcgisApi::Auth::Token,nil] + def call(auth:, cache:) + cache.token_entry + end + # rubocop:enable Lint/UnusedMethodArgument + end +end diff --git a/app/services/arcgis_api/auth/refresh/noop_refresh_strategy.rb b/app/services/arcgis_api/auth/refresh/noop_refresh_strategy.rb new file mode 100644 index 00000000000..bda3e40095a --- /dev/null +++ b/app/services/arcgis_api/auth/refresh/noop_refresh_strategy.rb @@ -0,0 +1,13 @@ +module ArcgisApi::Auth::Refresh + # Does nothing, returns nil + class NoopRefreshStrategy < RefreshStrategy + # rubocop:disable Lint/UnusedMethodArgument + # @param [ArcgisApi::Auth::Authentication] auth + # @param [ArcgisApi::Auth::Cache::TokenCacheWriter] cache + # @return [nil] + def call(auth:, cache:) + nil + end + # rubocop:enable Lint/UnusedMethodArgument + end +end diff --git a/app/services/arcgis_api/auth/refresh/on_expire_refresh_strategy.rb b/app/services/arcgis_api/auth/refresh/on_expire_refresh_strategy.rb new file mode 100644 index 00000000000..e393026539b --- /dev/null +++ b/app/services/arcgis_api/auth/refresh/on_expire_refresh_strategy.rb @@ -0,0 +1,16 @@ +module ArcgisApi::Auth::Refresh + # Refreshes a token when it's expired + class OnExpireRefreshStrategy < RefreshStrategy + # @param [ArcgisApi::Auth::Authentication] auth + # @param [ArcgisApi::Auth::Cache::TokenCacheWriter] cache + # @return [ArcgisApi::Auth::Token] + def call(auth:, cache:) + token_entry = cache.token_entry + if token_entry.nil? || token_entry.expired? + token_entry = auth.retrieve_token + cache.save_token(token_entry) + end + token_entry + end + end +end diff --git a/app/services/arcgis_api/auth/refresh/refresh_strategy.rb b/app/services/arcgis_api/auth/refresh/refresh_strategy.rb new file mode 100644 index 00000000000..affb796f462 --- /dev/null +++ b/app/services/arcgis_api/auth/refresh/refresh_strategy.rb @@ -0,0 +1,9 @@ +module ArcgisApi::Auth::Refresh + # Refreshes a token when it's expired + class RefreshStrategy + # @param [ArcgisApi::Auth::Authentication] auth + # @param [ArcgisApi::Auth::Cache::TokenCacheWriter] cache + # @return [ArcgisApi::Auth::Token,nil] + def call(auth:, cache:); end + end +end diff --git a/app/services/arcgis_api/auth/refresh/sliding_window_refresh_strategy.rb b/app/services/arcgis_api/auth/refresh/sliding_window_refresh_strategy.rb new file mode 100644 index 00000000000..f40974b9322 --- /dev/null +++ b/app/services/arcgis_api/auth/refresh/sliding_window_refresh_strategy.rb @@ -0,0 +1,68 @@ +module ArcgisApi::Auth::Refresh + # Applies a sliding window strategy to reduce contention + # related to refreshing the token. + # + # When sliding_expires_at<= current time <= sliding_expires_at + prefetch_ttl, + # the entry's sliding_expires_at time is updated to + # sliding_expires_at + prefetch_ttl and a new token is requested and saved to cache. + # + # When sliding_expires_at + prefetch_ttl < current time, + # a new token is requested and saved to cache. + # + # Optimistically, the token in cache will NOT expire + # when accessed by multi-threaded/distributed clients, + # since there is about expires_at - 2*prefetch_ttl + # length of time to generate a new API token. + class SlidingWindowRefreshStrategy < RefreshStrategy + # @param [Number] sliding_increment_seconds size of increment to use for the sliding window + # @param [Number] sliding_times times to move the window by sliding_increment_seconds + def initialize(sliding_increment_seconds: nil, sliding_times: nil) + @sliding_increment_seconds = ( + sliding_increment_seconds || + arcgis_api_token_prefetch_ttl_sliding_increment_seconds + ) + @sliding_times = ( + sliding_times || + arcgis_api_token_prefetch_ttl_sliding_times + ) + + # Add jitter to reduce the likelihood that cache updates + # will coincide with other sources of load on the API + # and cache servers + @sliding_increment_seconds += (rand - 0.5) + end + + # @param [ArcgisApi::Auth::Authentication] auth + # @param [ArcgisApi::Auth::Cache::TokenCacheWriter] cache + # @return [ArcgisApi::Auth::Token] + def call(auth:, cache:) + token_entry = cache.token_entry + if token_entry.present? + return token_entry unless token_entry.sliding_window_expired? + + # If we've reached the sliding window, then extend the + # sliding window to prevent other servers from refreshing + # the same token + unless token_entry.expired? + token_entry.sliding_expires_at += sliding_increment_seconds + cache.save_token(token_entry) + end + end + + new_token = auth.retrieve_token + + sliding_expires_at = sliding_increment_seconds * sliding_times + new_token.sliding_expires_at = new_token.expires_at - sliding_expires_at + + cache.save_token(new_token) + new_token + end + + private + + delegate :arcgis_api_token_prefetch_ttl_sliding_increment_seconds, + :arcgis_api_token_prefetch_ttl_sliding_times, + to: :"IdentityConfig.store" + attr_accessor :sliding_increment_seconds, :sliding_times + end +end diff --git a/app/services/arcgis_api/auth/token.rb b/app/services/arcgis_api/auth/token.rb new file mode 100644 index 00000000000..6bc4b483625 --- /dev/null +++ b/app/services/arcgis_api/auth/token.rb @@ -0,0 +1,35 @@ +module ArcgisApi::Auth + # Struct to store token information, this allows us to track + # real expiration time with various rails cache backends many of them + # do not support entry expiration. + # Attributes + # token: the token string + # expires_at: hard expiration timestamp in epoch seconds + # sliding_expires_at: optional the token keeper to maintain for + # sliding expiration when sliding expiration enabled. + # A time that the token does not actually expire + # but used to control the timing of requesting a new token before it expires. + # It's initially set to expires_at - 3*prefetch_ttl. + class Token < Struct.new( + :token, + :expires_at, + :sliding_expires_at, + ) + + # Check if the token is expired + # @return [Boolean] + def expired? + expires_at.present? && expires_at <= Time.zone.now.to_f + end + + # Check if the sliding window has been reached + # + # If there is no sliding window or if the hard expiry has been reached, + # then this will correspond with whether the token is expired. + # + # @return [Boolean] + def sliding_window_expired? + (sliding_expires_at.present? && sliding_expires_at <= Time.zone.now.to_f) || expired? + end + end +end diff --git a/app/services/arcgis_api/faraday/configuration.rb b/app/services/arcgis_api/faraday/configuration.rb new file mode 100644 index 00000000000..d5cbbd577c9 --- /dev/null +++ b/app/services/arcgis_api/faraday/configuration.rb @@ -0,0 +1,49 @@ +# Utility functions for configuring Faraday for use with the ArcGIS API +module ArcgisApi::Faraday::Configuration + # Configure Faraday to communicate with the ArcGIS API + # + # @param [Faraday::Connection] conn + def self.setup(conn) + # Log request metrics + conn.request :instrumentation, name: 'request_metric.faraday' + conn.options.timeout = arcgis_api_request_timeout_seconds + # Parse JSON responses + conn.response :json, content_type: 'application/json' + # Raise an error subclassing Faraday::Error on 4xx, 5xx, and malformed responses + # Note: The order of this matters for parsing the error response body. + conn.response :raise_error + conn.use ArcgisApi::Faraday::ResponseValidation + end + + # Configure retries on the Faraday connection, and optionally + # configure the retry_block parameter for the retry middleware. + # + # Also see: https://github.com/lostisland/faraday-retry + # + # @param [Faraday::Connection] conn + # @yield [env:, options:, retry_count:, exception:, will_retry_in:] + def self.add_retry(conn, &block) + faraday_retry_options = { + max: arcgis_get_token_max_retries, + methods: %i[post], + interval: arcgis_get_token_retry_interval_seconds, + interval_randomness: 0.25, + backoff_factor: arcgis_get_token_retry_backoff_factor, + } + + # If a block was given, then run it before each retry + faraday_retry_options[:retry_block] = Proc.new(&block) if block + + conn.request :retry, faraday_retry_options + end + + class << self + private + + delegate :arcgis_get_token_max_retries, + :arcgis_get_token_retry_interval_seconds, + :arcgis_get_token_retry_backoff_factor, + :arcgis_api_request_timeout_seconds, + to: :"IdentityConfig.store" + end +end diff --git a/app/services/arcgis_api/faraday/error.rb b/app/services/arcgis_api/faraday/error.rb new file mode 100644 index 00000000000..adad8bcbac8 --- /dev/null +++ b/app/services/arcgis_api/faraday/error.rb @@ -0,0 +1,9 @@ +module ArcgisApi::Faraday + # Raised when the ArcGIS API returns an error that + # uses a 2xx HTTP status code. + # + # This extends from Faraday::Error to preserve LSP + # while allowing for distinct handling. + class Error < Faraday::Error + end +end diff --git a/app/services/arcgis_api/faraday/response_validation.rb b/app/services/arcgis_api/faraday/response_validation.rb new file mode 100644 index 00000000000..81646e3a10b --- /dev/null +++ b/app/services/arcgis_api/faraday/response_validation.rb @@ -0,0 +1,35 @@ +module ArcgisApi::Faraday + # Faraday middleware to handle ArcGIS errors + # + # The ArcGIS API returns errors that use a 2xx status code, + # where it's necessary to parse the request body in order to + # determine whether and what type of error has occurred. + # + # The error handling strategy isn't well-documented on a REST + # API level, only at the level of ArcGIS's SDKs. However the + # source for esri/arcgis-rest-request suggests what types of + # errors we can expect. + class ResponseValidation < Faraday::Middleware + # @param [Faraday::Env] env + def on_complete(env) + return unless (200..299).cover?(env.status) + response_body = begin + JSON.parse(env.body) + rescue + nil + end + return unless response_body.is_a?(Hash) + return unless response_body.fetch('error', false) + + # response_body is in this format: + # {"error"=>{"code"=>400, "message"=>"", "details"=>[""]}} + error_code = response_body.dig('error', 'code') + error_message = response_body.dig('error', 'message') || "Received error code #{error_code}" + err = ArcgisApi::Faraday::Error.new( + "Error received from ArcGIS API: #{error_code}:#{error_message}", + env[:response], + ) + raise err + end + end +end diff --git a/app/services/arcgis_api/geocoder.rb b/app/services/arcgis_api/geocoder.rb index 8976beece9b..6954a966d69 100644 --- a/app/services/arcgis_api/geocoder.rb +++ b/app/services/arcgis_api/geocoder.rb @@ -6,8 +6,6 @@ class Geocoder keyword_init: true ) Location = Struct.new(:latitude, :longitude, keyword_init: true) - API_TOKEN_HOST = URI(IdentityConfig.store.arcgis_api_generate_token_url).host - API_TOKEN_CACHE_KEY = "arcgis_api_token:#{API_TOKEN_HOST}" # These are option URL params that tend to apply to multiple endpoints # https://developers.arcgis.com/rest/geocode/api-reference/geocoding-find-address-candidates.htm#ESRI_SECTION2_38613C3FCB12462CAADD55B2905140BF @@ -41,24 +39,10 @@ class Geocoder :SingleLine, # Unvalidated address-like text string used to search for geocoded addresses ] - # Makes an HTTP request to quickly find potential address matches. Each match that is found - # will include an associated magic_key value which can later be used to get more details about - # the address using the #find_address_candidates method - # Requests text input and will only match possible addresses - # A maximum of 5 suggestions are included in the suggestions array. - # @param text [String] - # @return [Array] Suggestions - def suggest(text) - params = { - text: text, - **COMMON_DEFAULT_PARAMETERS, - } + private attr_accessor :token_keeper - parse_suggestions( - faraday.get(IdentityConfig.store.arcgis_api_suggest_url, params, dynamic_headers) do |req| - req.options.context = { service_name: 'arcgis_geocoder_suggest' } - end.body, - ) + def initialize(token_keeper: TokenKeeper.new) + @token_keeper = token_keeper end # Makes HTTP request to find a full address record using a magic key or single text line @@ -83,68 +67,27 @@ def find_address_candidates(**options) } parse_address_candidates( - faraday.get( - IdentityConfig.store.arcgis_api_find_address_candidates_url, params, - dynamic_headers + connection.get( + IdentityConfig.store.arcgis_api_find_address_candidates_url, params, dynamic_headers ) do |req| req.options.context = { service_name: 'arcgis_geocoder_find_address_candidates' } end.body, ) end - # Makes a request to retrieve a new token - # it expires after 1 hour - # @return [String] Auth token - def retrieve_token! - token, expires = request_token.fetch_values('token', 'expires') - expires_at = Time.zone.at(expires / 1000) - Rails.cache.write(API_TOKEN_CACHE_KEY, token, expires_at: expires_at) - # If using a redis cache we have to manually set the expires_at. This is because we aren't - # using a dedicated Redis cache and instead are just using our existing Redis server with - # mixed usage patterns. Without this cache entries don't expire. - # More at https://api.rubyonrails.org/classes/ActiveSupport/Cache/RedisCacheStore.html - Rails.cache.try(:redis)&.expireat(API_TOKEN_CACHE_KEY, expires_at.to_i) - token - end - - # Checks the cache for an unexpired token and returns that. - # If the cache has expired, retrieves a new token and returns it - # @return [String] Auth token def token - Rails.cache.read(API_TOKEN_CACHE_KEY) || retrieve_token! + token_keeper.token end private - def faraday - Faraday.new do |conn| - # Log request metrics - conn.request :instrumentation, name: 'request_metric.faraday' - - conn.options.timeout = IdentityConfig.store.arcgis_api_request_timeout_seconds - - # Raise an error subclassing Faraday::Error on 4xx, 5xx, and malformed responses - # Note: The order of this matters for parsing the error response body. - conn.response :raise_error - - # Parse JSON responses - conn.response :json, content_type: 'application/json' - + def connection + ::Faraday.new do |conn| + ArcgisApi::Faraday::Configuration.setup(conn) yield conn if block_given? end end - def parse_suggestions(response_body) - handle_api_errors(response_body) - - response_body['suggestions'].map do |suggestion| - Suggestion.new( - text: suggestion['text'], - magic_key: suggestion['magicKey'], - ) - end - end - def parse_address_candidates(response_body) handle_api_errors(response_body) @@ -180,7 +123,6 @@ def handle_api_errors(response_body) response_status_code: error_code, api_status_code: error_code, ) - Rails.cache.delete(API_TOKEN_CACHE_KEY) # this might only be needed for local testing raise Faraday::ClientError.new( RuntimeError.new(error_message), { @@ -199,26 +141,6 @@ def dynamic_headers { 'Authorization' => "Bearer #{token}" } end - # Makes HTTP request to authentication endpoint and - # returns the token and when it expires (1 hour). - # @return [Hash] API response - def request_token - body = { - username: IdentityConfig.store.arcgis_api_username, - password: IdentityConfig.store.arcgis_api_password, - referer: IdentityConfig.store.domain_name, - f: 'json', - } - - faraday.post( - IdentityConfig.store.arcgis_api_generate_token_url, URI.encode_www_form(body) - ) do |req| - req.options.context = { service_name: 'arcgis_token' } - end.body.tap do |body| - handle_api_errors(body) - end - end - def analytics(user: AnonymousUser.new) Analytics.new(user: user, request: nil, session: {}, sp: nil) end diff --git a/app/services/arcgis_api/mock/fixtures.rb b/app/services/arcgis_api/mock/fixtures.rb index caf2348ce20..34bb5cb6d2b 100644 --- a/app/services/arcgis_api/mock/fixtures.rb +++ b/app/services/arcgis_api/mock/fixtures.rb @@ -1,18 +1,6 @@ module ArcgisApi module Mock class Fixtures - def self.request_suggestions_response - generate_suggestions.to_json - end - - def self.request_suggestions_error - load_response_fixture('request_suggestions_error.json') - end - - def self.request_suggestions_error_html - load_response_fixture('request_suggestions_error.html') - end - def self.request_candidates_response generate_address_candidates.to_json end @@ -29,18 +17,6 @@ def self.load_response_fixture(filename) Rails.root.join('spec', 'fixtures', 'arcgis_responses', filename).read end - def self.generate_suggestions(count = 5) - { - suggestions: Array.new(count) do |index| - { - text: Faker::Address.full_address, - magicKey: index.to_s, - isCollection: false, - } - end, - } - end - def self.generate_address_candidates(count = 5) { candidates: Array.new(count) do @@ -68,7 +44,7 @@ def self.request_token_service_error def self.invalid_gis_token_credentials_response load_response_fixture('invalid_gis_token_credentials_response.json') end - private_class_method :generate_suggestions, :generate_address_candidates + private_class_method :generate_address_candidates end end end diff --git a/app/services/arcgis_api/mock/geocoder.rb b/app/services/arcgis_api/mock/geocoder.rb index d27b5a6f610..2db34c38b7f 100644 --- a/app/services/arcgis_api/mock/geocoder.rb +++ b/app/services/arcgis_api/mock/geocoder.rb @@ -1,13 +1,12 @@ module ArcgisApi module Mock - class Geocoder < ArcgisApi::Geocoder - def faraday - super do |conn| - conn.adapter :test do |stub| - stub_generate_token(stub) - stub_suggestions(stub) - stub_address_candidates(stub) - end + class TokenKeeper < ArcgisApi::TokenKeeper + def connection + stubs = Faraday::Adapter::Test::Stubs.new do |stub| + stub_generate_token(stub) + end + super do |con| + con.adapter :test, stubs end end @@ -20,19 +19,51 @@ def stub_generate_token(stub) { 'Content-Type': 'application/json' }, { token: '1234', - expires: 1234, + expires: (Time.zone.now + 1.minute).to_f * 1000, ssl: true, }.to_json, ] end end + end + + class Geocoder < ArcgisApi::Geocoder + # def faraday + # super do |conn| + # conn.adapter :test do |stub| + # stub_generate_token(stub) + # stub_address_candidates(stub) + # end + # end + # end + + def initialize + token_keeper = TokenKeeper.new + super(token_keeper: token_keeper) + end - def stub_suggestions(stub) - stub.get(IdentityConfig.store.arcgis_api_suggest_url) do |env| + def connection + stubs = Faraday::Adapter::Test::Stubs.new do |stub| + stub_generate_token(stub) + stub_address_candidates(stub) + end + super do |con| + con.adapter :test, stubs + end + end + + private + + def stub_generate_token(stub) + stub.post(IdentityConfig.store.arcgis_api_generate_token_url) do |env| [ 200, { 'Content-Type': 'application/json' }, - ArcgisApi::Mock::Fixtures.request_suggestions_response, + { + token: '1234', + expires: (Time.zone.now + 30.seconds).to_f * 1000, + ssl: true, + }.to_json, ] end end diff --git a/app/services/arcgis_api/token_keeper.rb b/app/services/arcgis_api/token_keeper.rb new file mode 100644 index 00000000000..fcf32eb5f1f --- /dev/null +++ b/app/services/arcgis_api/token_keeper.rb @@ -0,0 +1,67 @@ +module ArcgisApi + # Class to retrieve, refresh and manage caching of Arcgis API token. + # If token synchronous fetching is disabled, a token will be fetched from + # cache directly or it will be a cache miss. + # Otherwise the thread will try to fetch/refresh the token on demand as needed. + class TokenKeeper + def initialize(auth: nil, cache: nil) + @auth = auth || ArcgisApi::Auth::Authentication.new + + if arcgis_token_sliding_expiration_enabled + @cache = cache || ArcgisApi::Auth::Cache::TokenCacheInfoWriter.new + else + # Use backwards-compatible cache until we have sliding expiration + # enabled. + @cache = cache || ArcgisApi::Auth::Cache::TokenCacheRawWriter.new + end + end + + # Refresh the token if needed. For use when deliberately trying + # to refresh the token without attempting an additional request. + def refresh_token + refresh_strategy.call(auth:, cache:) + end + + # Refresh the token when needed, then return the token. For + # use when calling through from another request to refresh + # the token. + # + # @return [String] token + def token + thru_strategy.call(auth:, cache:)&.token + end + + private + + def refresh_strategy + @refresh_strategy ||= begin + if arcgis_token_refresh_job_enabled + ArcgisApi::Auth::Refresh::AlwaysRefreshStrategy.new + else + ArcgisApi::Auth::Refresh::NoopRefreshStrategy.new + end + end + end + + def thru_strategy + @thru_strategy ||= begin + if arcgis_token_sync_request_enabled + if arcgis_token_sliding_expiration_enabled + ArcgisApi::Auth::Refresh::SlidingWindowRefreshStrategy.new + else + ArcgisApi::Auth::Refresh::OnExpireRefreshStrategy.new + end + else + ArcgisApi::Auth::Refresh::FetchWithoutRefreshStrategy.new + end + end + end + + delegate :arcgis_token_sliding_expiration_enabled, + :arcgis_token_sync_request_enabled, + :arcgis_token_refresh_job_enabled, + to: :"IdentityConfig.store" + + attr_accessor :auth, :cache + end +end diff --git a/config/application.yml.default b/config/application.yml.default index 73ff2d5d0f0..daa66f6610e 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -46,9 +46,17 @@ arcgis_api_username: '' arcgis_api_password: '' arcgis_mock_fallback: true arcgis_api_generate_token_url: 'https://gis.gsa.gov/portal/sharing/rest/generateToken' -arcgis_api_suggest_url: 'https://gis.gsa.gov/servernh/rest/services/GSA/USA/GeocodeServer/suggest' arcgis_api_find_address_candidates_url: 'https://gis.gsa.gov/servernh/rest/services/GSA/USA/GeocodeServer/findAddressCandidates' arcgis_api_request_timeout_seconds: 5 +arcgis_api_token_prefetch_ttl_sliding_increment_seconds: 10 +arcgis_api_token_prefetch_ttl_sliding_times: 3 +arcgis_api_token_cache_key_prefix: arcgis_api_token +arcgis_get_token_max_retries: 5 +arcgis_get_token_retry_interval_seconds: 1 +arcgis_get_token_retry_backoff_factor: 2 +arcgis_token_sliding_expiration_enabled: false +arcgis_token_sync_request_enabled: true +arcgis_token_refresh_job_enabled: true asset_host: '' async_wait_timeout_seconds: 60 async_stale_job_timeout_seconds: 300 @@ -523,6 +531,7 @@ production: test: aamva_private_key: 123abc aamva_public_key: 123abc + arcgis_token_sync_request_enabled: true acuant_assure_id_url: https://example.com acuant_facial_match_url: https://facial_match.example.com acuant_passlive_url: https://liveness.example.com diff --git a/config/initializers/job_configurations.rb b/config/initializers/job_configurations.rb index 50163c4f8b5..e0f1f298725 100644 --- a/config/initializers/job_configurations.rb +++ b/config/initializers/job_configurations.rb @@ -3,6 +3,7 @@ cron_24h = '0 0 * * *' gpo_cron_24h = '0 10 * * *' # 10am UTC is 5am EST/6am EDT cron_1w = '0 0 * * 0' +cron_55m = '0/55 * * * *' if defined?(Rails::Console) Rails.logger.info 'job_configurations: console detected, skipping schedule' @@ -169,6 +170,10 @@ cron: cron_24h, args: -> { [Time.zone.yesterday] }, }, + arcgis_token: { + class: 'ArcgisTokenJob', + cron: cron_55m, + }, } end # rubocop:enable Metrics/BlockLength diff --git a/lib/identity_config.rb b/lib/identity_config.rb index e449fe9382f..4f0c61055af 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -123,9 +123,17 @@ def self.build_store(config_map) config.add(:arcgis_api_password, type: :string) config.add(:arcgis_mock_fallback, type: :boolean) config.add(:arcgis_api_generate_token_url, type: :string) - config.add(:arcgis_api_suggest_url, type: :string) config.add(:arcgis_api_find_address_candidates_url, type: :string) config.add(:arcgis_api_request_timeout_seconds, type: :integer) + config.add(:arcgis_api_token_cache_key_prefix, type: :string) + config.add(:arcgis_api_token_prefetch_ttl_sliding_increment_seconds, type: :integer) + config.add(:arcgis_api_token_prefetch_ttl_sliding_times, type: :integer) + config.add(:arcgis_get_token_max_retries, type: :integer) + config.add(:arcgis_get_token_retry_interval_seconds, type: :integer) + config.add(:arcgis_get_token_retry_backoff_factor, type: :integer) + config.add(:arcgis_token_sync_request_enabled, type: :boolean) + config.add(:arcgis_token_refresh_job_enabled, type: :boolean) + config.add(:arcgis_token_sliding_expiration_enabled, type: :boolean) config.add(:aws_http_retry_limit, type: :integer) config.add(:aws_http_retry_max_delay, type: :integer) config.add(:aws_http_timeout, type: :integer) diff --git a/spec/features/idv/in_person_spec.rb b/spec/features/idv/in_person_spec.rb index 5fc48aa456b..9cbb3cc32d6 100644 --- a/spec/features/idv/in_person_spec.rb +++ b/spec/features/idv/in_person_spec.rb @@ -10,6 +10,7 @@ allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) allow(IdentityConfig.store).to receive(:in_person_capture_secondary_id_enabled). and_return(false) + allow(IdentityConfig.store).to receive(:arcgis_token_sync_request_enabled).and_return(true) end context 'ThreatMetrix review pending' do diff --git a/spec/fixtures/arcgis_responses/request_suggestions_error.html b/spec/fixtures/arcgis_responses/request_suggestions_error.html deleted file mode 100644 index 813171335ce..00000000000 --- a/spec/fixtures/arcgis_responses/request_suggestions_error.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - Error: The administrator has disabled the Services Directory. - - - - - - - - -
ArcGIS REST Framework
- - - - - -
-
-
- Error: The administrator has disabled the Services Directory.
- Code: 403

-
-
- - - diff --git a/spec/fixtures/arcgis_responses/request_suggestions_error.json b/spec/fixtures/arcgis_responses/request_suggestions_error.json deleted file mode 100644 index 04a5a47d0c7..00000000000 --- a/spec/fixtures/arcgis_responses/request_suggestions_error.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "error": { - "code": 400, - "extendedCode": -2147467259, - "message": "Unable to complete operation.", - "details": [ - "Something went wrong" - ] - } -} diff --git a/spec/jobs/arcgis_token_job_spec.rb b/spec/jobs/arcgis_token_job_spec.rb new file mode 100644 index 00000000000..b75f0822ad1 --- /dev/null +++ b/spec/jobs/arcgis_token_job_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe ArcgisTokenJob, type: :job do + let(:token_keeper) { instance_spy(ArcgisApi::TokenKeeper) } + let(:job) { described_class.new(token_keeper: token_keeper) } + let(:analytics) { instance_spy(Analytics) } + describe 'arcgis token job' do + it 'fetches token successfully' do + allow(job).to receive(:analytics).and_return(analytics) + allow(job).to receive(:token_keeper).and_return(token_keeper) + allow(token_keeper).to receive(:retrieve_token).and_return(ArcgisApi::Auth::Token.new) + expect(job.perform).to eq(true) + expect(token_keeper).to have_received(:retrieve_token).once + expect(token_keeper).to have_received(:save_token).once + expect(analytics).to have_received( + :idv_arcgis_token_job_started, + ).once + expect(analytics).to have_received( + :idv_arcgis_token_job_completed, + ).once + end + end +end diff --git a/spec/services/arcgis_api/connection_factory_spec.rb b/spec/services/arcgis_api/connection_factory_spec.rb new file mode 100644 index 00000000000..3a4051d0097 --- /dev/null +++ b/spec/services/arcgis_api/connection_factory_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' +RSpec.describe ArcgisApi::ConnectionFactory do + let(:subject) { described_class.new } + + context 'Create new connection' do + it 'create connection successfully' do + test_message = 'This is a test' + test_response = <<-BODY + { + "message": "#{test_message}" + } + BODY + stub_request(:get, 'https://google.com/'). + with( + headers: { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Faraday v2.7.4', + }, + ). + to_return(status: 200, body: test_response, headers: { content_type: 'application/json' }) + + conn = subject.connection do |con| + expect(con).to be_instance_of(Faraday::Connection) + end + + res = conn.get('https://google.com') do |req| + req.options.context = { service_name: 'arcgis_geocoder_suggest' } + end + expect(res.body.fetch('message')).to eq(test_message) + end + end +end diff --git a/spec/services/arcgis_api/geocoder_spec.rb b/spec/services/arcgis_api/geocoder_spec.rb index 10982dc880e..be747ffa24c 100644 --- a/spec/services/arcgis_api/geocoder_spec.rb +++ b/spec/services/arcgis_api/geocoder_spec.rb @@ -2,44 +2,13 @@ RSpec.describe ArcgisApi::Geocoder do include ArcgisApiHelper - + let(:cache_key) { 'test_arcgis_geocoder_token' } let(:subject) { ArcgisApi::Geocoder.new } - describe '#suggest' do - before(:each) do - stub_generate_token_response - end - - it 'returns suggestions' do - stub_request_suggestions - - suggestions = subject.suggest('100 Main') - - expect(suggestions.first.magic_key).to be_present - expect(suggestions.first.text).to be_present - end - - it 'returns an error response body but with Status coded as 200' do - stub_request_suggestions_error - - expect { subject.suggest('100 Main') }.to raise_error do |error| - expect(error).to be_instance_of(Faraday::ClientError) - expect(error.message).to eq('Unable to complete operation.') - expect(error.response).to be_kind_of(Hash) - end - end - - it 'returns an error with Status coded as 4** in HTML' do - stub_request_suggestions_error_html - - expect { subject.suggest('100 Main') }.to raise_error( - an_instance_of(Faraday::BadRequestError), - ) - end - end - describe '#find_address_candidates' do before(:each) do + allow(IdentityConfig.store).to receive(:arcgis_api_token_cache_key_prefix). + and_return(cache_key) stub_generate_token_response end @@ -105,18 +74,17 @@ end end - describe '#retrieve_token!' do + describe '#token!' do it 'sets token and token_expires_at' do stub_generate_token_response - subject.retrieve_token! - - expect(subject.token).to be_present + token = subject.token + expect(token).to be_present end it 'attempts to generate a token with invalid credentials' do stub_invalid_token_credentials_response - expect { subject.suggest('100 Main') }.to raise_error do |error| + expect { subject.find_address_candidates(SingleLine: 'abc123') }.to raise_error do |error| expect(error.message).to eq('Unable to generate token.') end end @@ -124,7 +92,7 @@ it 'calls the token service with no response' do stub_token_service_unreachable_response - expect { subject.suggest('100 Main') }.to raise_error do |error| + expect { subject.find_address_candidates(SingleLine: 'abc123') }.to raise_error do |error| expect(error.message).to eq('Failed to open TCP connection') end end @@ -139,7 +107,7 @@ allow(IdentityConfig.store).to receive(:arcgis_api_password). and_return(password) - subject.retrieve_token! + subject.token expect(WebMock).to have_requested(:post, %r{/generateToken}). with( @@ -149,16 +117,16 @@ it 'reuses the cached token on subsequent requests' do stub_generate_token_response - stub_request_suggestions - stub_request_suggestions - stub_request_suggestions + stub_request_candidates_response + stub_request_candidates_response + stub_request_candidates_response - subject.suggest('1') - subject.suggest('100') - subject.suggest('100 Main') + subject.find_address_candidates(SingleLine: 'abc1') + subject.find_address_candidates(SingleLine: 'abc12') + subject.find_address_candidates(SingleLine: 'abc123') expect(WebMock).to have_requested(:post, %r{/generateToken}).once - expect(WebMock).to have_requested(:get, %r{/suggest}).times(3) + expect(WebMock).to have_requested(:get, %r{/findAddressCandidates}).times(3) end it 'implicitly refreshes the token when expired' do @@ -167,50 +135,32 @@ and_return(root_url) stub_generate_token_response(expires_at: 1.hour.from_now.to_i * 1000, token: 'token1') - stub_request_suggestions - subject.suggest('100 Main') + stub_request_candidates_response + subject.find_address_candidates(SingleLine: 'abc123') travel 2.hours stub_generate_token_response(token: 'token2') - stub_request_suggestions - subject.suggest('100 Main') + stub_request_candidates_response + subject.find_address_candidates(SingleLine: 'abc123') expect(WebMock).to have_requested(:post, %r{/generateToken}).twice - expect(WebMock).to have_requested(:get, %r{/suggest}). + expect(WebMock).to have_requested(:get, %r{/findAddressCandidates}). with(headers: { 'Authorization' => 'Bearer token1' }).once - expect(WebMock).to have_requested(:get, %r{/suggest}). + expect(WebMock).to have_requested(:get, %r{/findAddressCandidates}). with(headers: { 'Authorization' => 'Bearer token1' }).once end it 'reuses the cached token across instances' do stub_generate_token_response(token: 'token1') - stub_request_suggestions - stub_request_suggestions - - client1 = ArcgisApi::Geocoder.new - client2 = ArcgisApi::Geocoder.new + stub_request_candidates_response + stub_request_candidates_response - client1.suggest('1') - client2.suggest('100') + subject.find_address_candidates(SingleLine: 'abc12') + subject.find_address_candidates(SingleLine: 'abc123') - expect(WebMock).to have_requested(:get, %r{/suggest}). + expect(WebMock).to have_requested(:get, %r{/findAddressCandidates}). with(headers: { 'Authorization' => 'Bearer token1' }).twice end - - context 'when using redis as a backing store' do - before do |ex| - allow(Rails).to receive(:cache).and_return( - ActiveSupport::Cache::RedisCacheStore.new(url: IdentityConfig.store.redis_throttle_url), - ) - end - - it 'manually sets the expiration' do - stub_generate_token_response - subject.retrieve_token! - ttl = Rails.cache.redis.ttl(ArcgisApi::Geocoder::API_TOKEN_CACHE_KEY) - expect(ttl).to be > 0 - end - end end end diff --git a/spec/services/arcgis_api/token_keeper_spec.rb b/spec/services/arcgis_api/token_keeper_spec.rb new file mode 100644 index 00000000000..719ee8051d8 --- /dev/null +++ b/spec/services/arcgis_api/token_keeper_spec.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ArcgisApi::TokenKeeper do + let(:prefetch_ttl) { 1 } + let(:analytics) { instance_spy(Analytics) } + let(:cache_key) { 'test_arcgis_api_token' } + let(:subject) do + obj = described_class.new( + cache_key: cache_key, + prefetch_ttl: prefetch_ttl, + ) + obj.analytics = (analytics) + obj + end + + let(:expected) { 'ABCDEFG' } + let(:expected_sec) { 'GFEDCBA' } + let(:expires_at) { (Time.zone.now + 15.seconds).to_f * 1000 } + let(:cache) { Rails.cache } + + before(:each) do + allow(Rails).to receive(:cache).and_return(cache_store) + subject.remove_token + end + + shared_examples 'acquire token test' do + context 'token not expired and not in prefetch timeframe' do + it 'get same token at second call' do + stub_request(:post, %r{/generateToken}).to_return( + { status: 200, + body: { + token: expected, + expires: (Time.zone.now + 15.seconds).to_f * 1000, + ssl: true, + }.to_json, + headers: { content_type: 'application/json;charset=UTF-8' } }, + { status: 200, + body: { + token: expected_sec, + expires: (Time.zone.now + 1.hour).to_f * 1000, + ssl: true, + }.to_json, + headers: { content_type: 'application/json;charset=UTF-8' } }, + ) + # verify configuration + expect(subject.sliding_expiration_enabled).to be(true) + expect(IdentityConfig.store.arcgis_token_sync_request_enabled).to be(true) + + expect(Rails.cache).to receive(:read).with(kind_of(String)). + and_call_original + freeze_time do + token = subject.token + expect(token).to eq(expected) + end + + travel 1.second do + expect(Rails.cache).to receive(:read).with(kind_of(String)). + and_call_original + token = subject.token + expect(token).to eq(expected) + end + end + end + + context 'token not expired and but in prefetch timeframe' do + before(:each) do + stub_request(:post, %r{/generateToken}).to_return( + { status: 200, + body: { + token: expected, + expires: (Time.zone.now + 15.seconds).to_f * 1000, + ssl: true, + }.to_json, + headers: { content_type: 'application/json;charset=UTF-8' } }, + { status: 200, + body: { + token: expected_sec, + expires: (Time.zone.now + 1.hour).to_f * 1000, + ssl: true, + }.to_json, + headers: { content_type: 'application/json;charset=UTF-8' } }, + ) + end + context 'get token at different timing' do + let(:prefetch_ttl) { 3 } + it 'get same token between sliding_expires_at passed and sliding_expires_at+prefetch_ttl' do + expect(Rails.cache).to receive(:read).with(kind_of(String)). + and_call_original + freeze_time do + token = subject.token + expect(token).to eq(expected) + end + Rails.logger.debug { "#####now0=#{Time.zone.now.to_f}" } + + travel 1.second do + expect(Rails.cache).to receive(:read).with(kind_of(String)). + and_call_original + Rails.logger.debug { "#####now=#{Time.zone.now.to_f}" } + token = subject.token + Rails.logger.debug { "token sec : #{token}" } + expect(token).to eq(expected) + end + end + it 'regenerates token when passed sliding_expires_at+prefetch_ttl' do + expect(Rails.cache).to receive(:read).with(kind_of(String)). + and_call_original + + token = subject.token + expect(token).to eq(expected) + + travel 11.seconds do + expect(Rails.cache).to receive(:read).with(kind_of(String)). + and_call_original + token = subject.token + expect(token).to eq(expected_sec) + end + end + end + end + + context 'value only token in cache' do + before(:each) do + stub_request(:post, %r{/generateToken}).to_return( + { status: 200, + body: { + token: expected, + expires: (Time.zone.now + 15.seconds).to_f * 1000, + ssl: true, + }.to_json, + headers: { content_type: 'application/json;charset=UTF-8' } }, + ) + subject.save_token(expected, expires_at) + end + let(:prefetch_ttl) { 5 } + it 'should use deal with the value only token' do + token = subject.token + expect(token).to eq(expected) + end + end + end + context 'with in memory store' do + let(:cache_store) { ActiveSupport::Cache.lookup_store(:memory_store) } + context 'sliding expiration enabled' do + before(:each) do + allow(IdentityConfig.store).to receive(:arcgis_token_sliding_expiration_enabled). + and_return(true) + end + include_examples 'acquire token test' + end + end + context 'with redis store' do + let(:cache_store) do + ActiveSupport::Cache.lookup_store(:redis_cache_store, { url: IdentityConfig.store.redis_url }) + end + include_examples 'acquire token test' + context 'retry options' do + it 'retry remote request multiple times as needed and emit analytics events' do + allow(IdentityConfig.store).to receive(:arcgis_get_token_max_retries).and_return(5) + stub_request(:post, %r{/generateToken}).to_return( + { + status: 503, + }, + { + status: 200, + body: ArcgisApi::Mock::Fixtures.request_token_service_error, + headers: { content_type: 'application/json;charset=UTF-8' }, + }, + { + status: 200, + body: ArcgisApi::Mock::Fixtures.invalid_gis_token_credentials_response, + headers: { content_type: 'application/json;charset=UTF-8' }, + }, + { status: 200, + body: { + token: expected, + expires: (Time.zone.now + 1.hour).to_f * 1000, + ssl: true, + }.to_json, + headers: { content_type: 'application/json;charset=UTF-8' } }, + ) + token = subject.retrieve_token + expect(token&.token).to eq(expected) + expect(analytics).to have_received(:idv_arcgis_token_failure).exactly(3).times + end + + it 'raises exception after max retries and log event correctly' do + allow(IdentityConfig.store).to receive(:arcgis_get_token_max_retries).and_return(2) + stub_request(:post, %r{/generateToken}).to_return( + { + status: 503, + }, + { + status: 429, + }, + { + status: 504, + }, + ) + expect do + subject.retrieve_token + end.to raise_error(Faraday::Error) + + msgs = [] + expect(analytics).to have_received(:idv_arcgis_token_failure) { |method_args| + msg = method_args.fetch(:exception_message) + msgs << msg + }.exactly(2).times.ordered + expect(msgs[0]).to match(/retry count/) + expect(msgs[1]).to match(/max retries/) + end + end + context 'token sync request disabled' do + it 'does not fetch token' do + allow(IdentityConfig.store).to receive(:arcgis_token_sync_request_enabled). + and_return(false) + expect(subject.token).to be(nil) + end + end + context 'sync request enabled and sliding expiration disabled' do + let(:original_token) do + ArcgisApi::Auth::Token.new('12345', (Time.zone.now + 15.seconds).to_f) + end + before(:each) do + allow(IdentityConfig.store).to receive(:arcgis_token_sync_request_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:arcgis_token_sliding_expiration_enabled). + and_return(false) + stub_request(:post, %r{/generateToken}).to_return( + { status: 200, + body: { + token: expected, + expires: (Time.zone.now + 15.seconds).to_f * 1000, + ssl: true, + }.to_json, + headers: { content_type: 'application/json;charset=UTF-8' } }, + ) + # this will save raw token + subject.save_token(original_token, expires_at) + end + let(:prefetch_ttl) { 5 } + it 'should get token after existing one expired' do + freeze_time do + token = subject.token + expect(token).to eq(original_token.token) + end + travel 20.seconds do + # now simulate a cache miss, redis not affected by time travel + # Even with memory store, the original cache entry is a raw string + # rails cache won't expires it since no expiration information is available. + subject.remove_token + token = subject.token + expect(token).to eq(expected) + end + end + end + end +end + +RSpec.describe ArcgisApi::TokenExpirationStrategy do + describe 'when sliding_expiration_enabled is true' do + let(:subject) { ArcgisApi::TokenExpirationStrategy.new(sliding_expiration_enabled: true) } + it 'checks token expiration when now passed expires_at' do + expect(subject.expired?(token_info: nil)).to be(true) + token_info = ArcgisApi::Auth::Token.new(token: 'ABCDE') + # missing expires_at assume the token is valid + expect(subject.expired?(token_info: token_info)).to be(false) + freeze_time do + now = Time.zone.now.to_f + token_info.expires_at = now + expect(subject.expired?(token_info: token_info)).to be(true) + token_info.expires_at = now + 0.0001 + expect(subject.expired?(token_info: token_info)).to be(false) + end + end + context 'when sliding_expires_at <= now' do + it 'should expired and sliding_expires_at extended' do + freeze_time do + now = Time.zone.now.to_f + prefetch_ttl = 3 + token_info = ArcgisApi::Auth::Token.new( + token: 'ABCDE', + expires_at: now + 2 * prefetch_ttl, + sliding_expires_at: now - prefetch_ttl, + ) + expect(subject.expired?(token_info: token_info)).to be(true) + existing_sliding_expires = token_info.sliding_expires_at + subject.extend_sliding_expires_at(token_info: token_info, prefetch_ttl: prefetch_ttl) + expect(token_info.sliding_expires_at - existing_sliding_expires).to eq(prefetch_ttl) + end + end + end + end +end diff --git a/spec/support/arcgis_api_helper.rb b/spec/support/arcgis_api_helper.rb index 5ef1b3cf427..2c38e622b79 100644 --- a/spec/support/arcgis_api_helper.rb +++ b/spec/support/arcgis_api_helper.rb @@ -1,24 +1,4 @@ module ArcgisApiHelper - def stub_request_suggestions - stub_request(:get, %r{/suggest}).to_return( - status: 200, body: ArcgisApi::Mock::Fixtures.request_suggestions_response, - headers: { content_type: 'application/json;charset=UTF-8' } - ) - end - - def stub_request_suggestions_error - stub_request(:get, %r{/suggest}).to_return( - status: 200, body: ArcgisApi::Mock::Fixtures.request_suggestions_error, - headers: { content_type: 'application/json;charset=UTF-8' } - ) - end - - def stub_request_suggestions_error_html - stub_request(:get, %r{/suggest}).to_return( - status: 400, body: ArcgisApi::Mock::Fixtures.request_suggestions_error_html, - ) - end - def stub_request_candidates_response stub_request(:get, %r{/findAddressCandidates}).to_return( status: 200, body: ArcgisApi::Mock::Fixtures.request_candidates_response,