From 184fc1a0486a5eb4f33920ed8fa0388f73ed905c Mon Sep 17 00:00:00 2001 From: Paul Simpson Date: Thu, 20 Jun 2019 09:33:30 -0500 Subject: [PATCH] Adds a token_cache configuration option to share tokens across processes (#11) --- README.md | 13 ++ lib/help_scout/api.rb | 2 +- lib/help_scout/api/access_token.rb | 50 +++--- lib/help_scout/api/access_token/cache.rb | 40 +++++ lib/help_scout/api/access_token/request.rb | 36 ++++ lib/help_scout/api/client.rb | 17 +- lib/help_scout/configuration.rb | 11 +- ...eturns_a_new_access_token_from_the_API.yml | 44 +++++ .../writes_the_token_to_the_cache.yml | 44 +++++ spec/integration/access_token_spec.rb | 11 -- spec/integration/api/access_token_spec.rb | 35 ++++ spec/spec_helper.rb | 14 ++ spec/unit/access_token_spec.rb | 38 ---- spec/unit/api/access_token/cache_spec.rb | 123 +++++++++++++ spec/unit/api/access_token/request_spec.rb | 50 ++++++ spec/unit/api/access_token_spec.rb | 165 ++++++++++++++++++ 16 files changed, 612 insertions(+), 81 deletions(-) create mode 100644 lib/help_scout/api/access_token/cache.rb create mode 100644 lib/help_scout/api/access_token/request.rb create mode 100644 spec/cassettes/HelpScout_API_AccessToken/_create/when_caching_is_enabled/and_the_access_token_is_not_set_in_the_cache/requests_and_returns_a_new_access_token_from_the_API.yml create mode 100644 spec/cassettes/HelpScout_API_AccessToken/_create/when_caching_is_enabled/and_the_access_token_is_not_set_in_the_cache/writes_the_token_to_the_cache.yml delete mode 100644 spec/integration/access_token_spec.rb create mode 100644 spec/integration/api/access_token_spec.rb delete mode 100644 spec/unit/access_token_spec.rb create mode 100644 spec/unit/api/access_token/cache_spec.rb create mode 100644 spec/unit/api/access_token/request_spec.rb create mode 100644 spec/unit/api/access_token_spec.rb diff --git a/README.md b/README.md index 91ccdcd..179b52c 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,19 @@ HelpScout::User.list user = HelpScout::User.get(id) ``` +### Caching Access Tokens + +Since short-lived access tokens aren't likely to be embedded into environment variables, it can be difficult to share them across processes. To work around this, you can configure a `token_cache` (and optional `token_cache_key`) to be used to store and retrieve the token until expiry. In general any object that conforms to the `ActiveSupport::Cache::Store` API should work. For example, using an application's Rails cache: + +```ruby +HelpScout.configuration.token_cache = Rails.cache +HelpScout.configuration.token_cache_key +# => 'help_scout_token_cache' +HelpScout.configuration.token_cache_key = 'my-own-key' +``` + +With caching configured, whenever the gem attempts to create an access token, it will first attempt to read a value from the cache using the configured cache key. If it's a hit, the cached values will be used to create a new `AccessToken`. If it's a miss, then the gem will make a request to the Help Scout API to retrieve a new token, writing the token's details to the cache before returning the new token. + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. diff --git a/lib/help_scout/api.rb b/lib/help_scout/api.rb index 1dbae5c..7e84b94 100644 --- a/lib/help_scout/api.rb +++ b/lib/help_scout/api.rb @@ -52,7 +52,7 @@ def send_request(action, path, params) response = new_connection.send(action, path, params.compact) if response.status == 401 - access_token.invalidate! + access_token&.invalidate! response = new_connection.send(action, path, params.compact) end diff --git a/lib/help_scout/api/access_token.rb b/lib/help_scout/api/access_token.rb index 3db7c27..cda456e 100644 --- a/lib/help_scout/api/access_token.rb +++ b/lib/help_scout/api/access_token.rb @@ -1,32 +1,24 @@ # frozen_string_literal: true +require 'date' +require 'help_scout/api/access_token/cache' +require 'help_scout/api/access_token/request' + module HelpScout class API class AccessToken class << self def create - connection = HelpScout::API::Client.new(authorize: false).connection - response = connection.post('oauth2/token', token_request_params) - - case response.status - when 200 then new HelpScout::Response.new(response).body - when 429 then raise HelpScout::API::ThrottleLimitReached, response.body&.dig('error') - else raise HelpScout::API::InternalError, "unexpected response (status #{response.status})" - end - end + cache = HelpScout::API::AccessToken::Cache.new + request = HelpScout::API::AccessToken::Request.new - def refresh! - HelpScout.api.access_token = create + cache.configured? ? cache.fetch_token { request.execute } : request.execute end - private + def refresh! + return HelpScout.api.access_token unless HelpScout.access_token.nil? || HelpScout.access_token.stale? - def token_request_params - @_token_request_params ||= { - grant_type: 'client_credentials', - client_id: HelpScout.app_id, - client_secret: HelpScout.app_secret - } + HelpScout.api.access_token = create end end @@ -35,10 +27,20 @@ def token_request_params def initialize(params) @value = params[:access_token] - @expires_in = params[:expires_in] - return unless @expires_in - @expires_at = Time.now.utc + expires_in + if params[:expires_at] + @expires_at = DateTime.parse(params[:expires_at].to_s).to_time.utc + elsif params[:expires_in] + @expires_in = params[:expires_in].to_i + @expires_at = (Time.now.utc + @expires_in) + end + end + + def as_json(*) + { + access_token: value, + expires_at: expires_at + } end def expired? @@ -48,10 +50,14 @@ def expired? end def invalid? - invalid + !!invalid # rubocop:disable Style/DoubleNegation end def invalidate! + cache = HelpScout::API::AccessToken::Cache.new + + cache.delete if cache.configured? + self.invalid = true end diff --git a/lib/help_scout/api/access_token/cache.rb b/lib/help_scout/api/access_token/cache.rb new file mode 100644 index 0000000..22cee8f --- /dev/null +++ b/lib/help_scout/api/access_token/cache.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module HelpScout + class API + class AccessToken + class Cache + attr_reader :backend, :key + + def initialize(backend: nil, key: nil) + @backend = backend || HelpScout.configuration.token_cache + @key = key || HelpScout.configuration.token_cache_key + end + + def configured? + backend.present? && key.present? + end + + def delete + backend.delete(key) + end + + def fetch_token(&token_request) + raise ArgumentError, 'A request fallback block is required' unless block_given? + + if (cached_token_json = backend.read(key)) + AccessToken.new(JSON.parse(cached_token_json, symbolize_names: true)) + else + token_request.call.tap { |token| write(token) } + end + end + + private + + def write(token) + backend.write(key, token.to_json, expires_in: token.expires_in) + end + end + end + end +end diff --git a/lib/help_scout/api/access_token/request.rb b/lib/help_scout/api/access_token/request.rb new file mode 100644 index 0000000..133f4ab --- /dev/null +++ b/lib/help_scout/api/access_token/request.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module HelpScout + class API + class AccessToken + class Request + attr_reader :response + + def execute + @response = request_token + end + + private + + def request_token + connection = API::Client.new(authorize: false).connection + http_response = connection.post('oauth2/token', token_request_params) + + case http_response.status + when 200 then AccessToken.new(Response.new(http_response).body) + when 429 then raise API::ThrottleLimitReached, http_response.body&.dig('error') + else raise API::InternalError, "unexpected response (status #{http_response.status})" + end + end + + def token_request_params + @_token_request_params ||= { + grant_type: 'client_credentials', + client_id: HelpScout.app_id, + client_secret: HelpScout.app_secret + } + end + end + end + end +end diff --git a/lib/help_scout/api/client.rb b/lib/help_scout/api/client.rb index e68a656..330f07d 100644 --- a/lib/help_scout/api/client.rb +++ b/lib/help_scout/api/client.rb @@ -10,14 +10,20 @@ def initialize(authorize: true) end def connection - @_connection ||= begin - HelpScout::API::AccessToken.refresh! if authorize? && token_needs_refresh? - build_connection + @_connection ||= build_connection.tap do |conn| + if authorize? + HelpScout::API::AccessToken.refresh! + conn.authorization(:Bearer, access_token) if access_token + end end end private + def access_token + HelpScout.access_token&.value + end + def authorize? authorize end @@ -25,15 +31,10 @@ def authorize? def build_connection Faraday.new(url: BASE_URL) do |conn| conn.request :json - conn.authorization(:Bearer, HelpScout.access_token.value) if authorize? && HelpScout.access_token&.value conn.response(:json, content_type: /\bjson$/) conn.adapter(Faraday.default_adapter) end end - - def token_needs_refresh? - HelpScout.access_token.nil? || HelpScout.access_token.stale? - end end end end diff --git a/lib/help_scout/configuration.rb b/lib/help_scout/configuration.rb index 2ecd30e..38ff205 100644 --- a/lib/help_scout/configuration.rb +++ b/lib/help_scout/configuration.rb @@ -2,13 +2,22 @@ module HelpScout class Configuration - attr_accessor :app_id, :app_secret, :default_mailbox + attr_accessor :app_id, :app_secret, :default_mailbox, :token_cache attr_reader :access_token + attr_writer :token_cache_key + + DEFAULT_CACHE_KEY = 'help_scout_token_cache' def access_token=(token_value) return unless token_value @access_token = HelpScout::API::AccessToken.new(access_token: token_value) end + + def token_cache_key + return @token_cache_key if defined?(@token_cache_key) + + @token_cache_key = DEFAULT_CACHE_KEY + end end end diff --git a/spec/cassettes/HelpScout_API_AccessToken/_create/when_caching_is_enabled/and_the_access_token_is_not_set_in_the_cache/requests_and_returns_a_new_access_token_from_the_API.yml b/spec/cassettes/HelpScout_API_AccessToken/_create/when_caching_is_enabled/and_the_access_token_is_not_set_in_the_cache/requests_and_returns_a_new_access_token_from_the_API.yml new file mode 100644 index 0000000..1b9d5ca --- /dev/null +++ b/spec/cassettes/HelpScout_API_AccessToken/_create/when_caching_is_enabled/and_the_access_token_is_not_set_in_the_cache/requests_and_returns_a_new_access_token_from_the_API.yml @@ -0,0 +1,44 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.helpscout.net/v2/oauth2/token + body: + encoding: UTF-8 + string: '{"grant_type":"client_credentials","client_id":"","client_secret":""}' + headers: + User-Agent: + - Faraday v0.15.4 + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Origin: + - "*" + Cache-Control: + - no-store + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 07 Jun 2019 09:10:28 GMT + Pragma: + - no-cache + Content-Length: + - '92' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"token_type":"bearer","access_token":"","expires_in":7200} + +' + http_version: + recorded_at: Fri, 07 Jun 2019 09:10:28 GMT +recorded_with: VCR 5.0.0 diff --git a/spec/cassettes/HelpScout_API_AccessToken/_create/when_caching_is_enabled/and_the_access_token_is_not_set_in_the_cache/writes_the_token_to_the_cache.yml b/spec/cassettes/HelpScout_API_AccessToken/_create/when_caching_is_enabled/and_the_access_token_is_not_set_in_the_cache/writes_the_token_to_the_cache.yml new file mode 100644 index 0000000..66842ba --- /dev/null +++ b/spec/cassettes/HelpScout_API_AccessToken/_create/when_caching_is_enabled/and_the_access_token_is_not_set_in_the_cache/writes_the_token_to_the_cache.yml @@ -0,0 +1,44 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.helpscout.net/v2/oauth2/token + body: + encoding: UTF-8 + string: '{"grant_type":"client_credentials","client_id":"","client_secret":""}' + headers: + User-Agent: + - Faraday v0.15.4 + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Origin: + - "*" + Cache-Control: + - no-store + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 07 Jun 2019 09:23:03 GMT + Pragma: + - no-cache + Content-Length: + - '92' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"token_type":"bearer","access_token":"","expires_in":7200} + +' + http_version: + recorded_at: Fri, 07 Jun 2019 09:23:03 GMT +recorded_with: VCR 5.0.0 diff --git a/spec/integration/access_token_spec.rb b/spec/integration/access_token_spec.rb deleted file mode 100644 index b432e41..0000000 --- a/spec/integration/access_token_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe HelpScout::API::AccessToken do - describe '.create' do - subject { described_class.create } - - it 'returns an AccessToken' do - expect(subject).to be_a described_class - end - end -end diff --git a/spec/integration/api/access_token_spec.rb b/spec/integration/api/access_token_spec.rb new file mode 100644 index 0000000..f398ead --- /dev/null +++ b/spec/integration/api/access_token_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'active_support/cache/memory_store' + +RSpec.describe HelpScout::API::AccessToken do + describe '.create' do + subject { described_class.create } + + it 'returns an AccessToken' do + expect(subject).to be_a described_class + end + + context 'when caching is configured' do + let(:cache) { double('token_cache') } + + around { |example| with_config(token_cache: cache) { example.run } } + + it 'attempts to fetch the token from the cache' do + expect_any_instance_of(HelpScout::API::AccessToken::Cache).to receive(:fetch_token) + + subject + end + end + + context 'when caching is not configured' do + around { |example| with_config(token_cache: nil) { example.run } } + + it 'attempts to request a token' do + expect_any_instance_of(HelpScout::API::AccessToken::Request).to receive(:execute) + + subject + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d464344..af9c298 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -38,6 +38,20 @@ def valid_access_token file_fixture('access_token.json') end +def with_config(new_configs = {}) + old_values = {} + + new_configs.each do |getter, new_value| + setter = "#{getter}=" + old_values[setter] = HelpScout.configuration.public_send(getter) + HelpScout.configuration.public_send(setter, new_value) + end + + yield if block_given? + + old_values.each { |setter, old_value| HelpScout.configuration.public_send(setter, old_value) } +end + HelpScout.configure do |config| config.app_id = ENV.fetch('HELP_SCOUT_APP_ID') config.app_secret = ENV.fetch('HELP_SCOUT_APP_SECRET') diff --git a/spec/unit/access_token_spec.rb b/spec/unit/access_token_spec.rb deleted file mode 100644 index 787dad9..0000000 --- a/spec/unit/access_token_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe HelpScout::API::AccessToken do - let(:access_token_params) { JSON.parse(access_token_json, symbolize_names: true) } - let(:access_token) { described_class.new(access_token_params) } - - describe '.create' do - subject { described_class.create } - - it { is_expected.to be_a described_class } - end - - describe '#value' do - subject { access_token.value } - - it { is_expected.to eq(access_token_params[:access_token]) } - end - - describe '#expires_in' do - subject { access_token.expires_in } - - it { is_expected.to eq(access_token_params[:expires_in]) } - end - - describe '#expired?' do - subject { access_token.expired? } - - context 'when the token is likely to be expired' do - let(:access_token_params) { super().merge(expires_in: -100) } - - it { is_expected.to eq true } - end - - context 'when the token is not likely to be expired' do - it { is_expected.to eq false } - end - end -end diff --git a/spec/unit/api/access_token/cache_spec.rb b/spec/unit/api/access_token/cache_spec.rb new file mode 100644 index 0000000..07eb9d4 --- /dev/null +++ b/spec/unit/api/access_token/cache_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'active_support/cache/memory_store' + +RSpec.describe HelpScout::API::AccessToken::Cache do + describe '#configured?' do + let(:cache) { described_class.new } + + subject { cache.configured? } + + context 'when no cache is configured' do + around { |example| with_config(token_cache: nil) { example.run } } + + it { is_expected.to be false } + end + + context 'when no cache key is configured' do + around { |example| with_config(token_cache_key: nil) { example.run } } + + it { is_expected.to be false } + end + + context 'when backend and key are provided' do + let(:configured_cache) do + described_class.new( + backend: double('backend'), + key: 'a-key' + ) + end + + subject { configured_cache.configured? } + + around { |example| with_config(token_cache: nil, token_cache_key: nil) { example.run } } + + it { is_expected.to be true } + end + end + + describe '#delete' do + let(:backend) { ActiveSupport::Cache::MemoryStore.new } + let(:cache) { described_class.new(backend: backend) } + + subject { cache.delete } + + it 'calls delete on the backend with the configured key' do + expect(backend).to receive(:delete).with(cache.key) + + subject + end + end + + describe '#fetch_token' do + let(:access_token_klass) { HelpScout::API::AccessToken } + let(:backend) { ActiveSupport::Cache::MemoryStore.new } + let(:cache) { described_class.new(backend: backend, key: key) } + let(:token) { access_token_klass.new(access_token: '12345', expires_in: 7200) } + let(:key) { 'some-unique-key' } + + context 'when a token request block is provided' do + let(:token_request) { -> { raise 'Token request block should not be called' } } + + subject { cache.fetch_token(&token_request) } + + context 'and the cache is a hit' do + around do |example| + backend.write(key, token.to_json) + example.run + backend.delete(key) + end + + it 'does not call the token_request block' do + expect(token_request).not_to receive(:call) + + subject + end + + it 'returns an AccessToken instance from the cached data' do + fetched_token = subject + + expect(fetched_token).to be_an(access_token_klass) + expect(fetched_token.value).to eq(token.value) + end + end + + context 'and the cache is a miss' do + let(:new_value) { 'newtoken' } + let(:new_token) { access_token_klass.new(access_token: new_value, expires_in: 7200) } + let(:token_request) { -> { new_token } } + + it 'calls the token_request block' do + expect(token_request).to receive(:call).and_return(new_token) + + subject + end + + it 'writes the new token to the cache backend' do + expect(backend).to receive(:write).with( + key, + new_token.to_json, + hash_including(:expires_in) + ) + + subject + end + + it 'returns an AccessToken instance from the requested response' do + requested_token = subject + + expect(requested_token).to be_an(access_token_klass) + expect(requested_token.value).to eq(new_value) + end + end + end + + context 'when no token request block is provided' do + subject { cache.fetch_token } + + it 'raises an ArgumentError' do + expect { subject }.to raise_error(ArgumentError, 'A request fallback block is required') + end + end + end +end diff --git a/spec/unit/api/access_token/request_spec.rb b/spec/unit/api/access_token/request_spec.rb new file mode 100644 index 0000000..4579bc0 --- /dev/null +++ b/spec/unit/api/access_token/request_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +RSpec.describe HelpScout::API::AccessToken::Request do + let(:request) { described_class.new } + + describe '#execute' do + subject { request.execute } + + before do + WebMock.reset! + stub_request(:post, api_path('oauth2/token')) + .to_return( + status: status, + body: body, + headers: { 'Content-Type' => 'application/json' } + ) + end + + context 'when the request is successful' do + let(:status) { 200 } + let(:body) { access_token_json } + + it 'returns an AccessToken instance from the requested response' do + expected_token = HelpScout::API::AccessToken.new(JSON.parse(body, symbolize_names: true)) + requested_token = subject + + expect(requested_token.value).to eq(expected_token.value) + end + end + + context 'when the request is rate limited' do + let(:status) { 429 } + let(:error) { { error: 'Request was throttled' } } + let(:body) { error.to_json } + + it 'raises an API::ThrottleLimitReached error' do + expect { subject }.to raise_error(HelpScout::API::ThrottleLimitReached, error[:error]) + end + end + + context 'when the request is not successful' do + let(:status) { 500 } + let(:body) { '' } + + it 'raises an API::InternalError error' do + expect { subject }.to raise_error(HelpScout::API::InternalError, "unexpected response (status #{status})") + end + end + end +end diff --git a/spec/unit/api/access_token_spec.rb b/spec/unit/api/access_token_spec.rb new file mode 100644 index 0000000..da67334 --- /dev/null +++ b/spec/unit/api/access_token_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +RSpec.describe HelpScout::API::AccessToken do + let(:access_token_params) { JSON.parse(access_token_json, symbolize_names: true) } + let(:access_token) { described_class.new(access_token_params) } + + describe '.new' do + context 'when an expires_at param is passed' do + let(:access_token_params) { super().merge(expires_at: Time.now.utc.to_s) } + + it 'sets expires_in to nil' do + expect(access_token.expires_in).to be_nil + end + end + end + + describe '.create' do + subject { described_class.create } + + it { is_expected.to be_a described_class } + end + + describe '.refresh!' do + subject { described_class.refresh! } + + context 'when the token is valid' do + before { HelpScout.api.access_token = access_token } + + it 'does not call create' do + expect(described_class).not_to receive(:create) + + subject + end + + it 'returns the token' do + expect(subject).to eq(access_token) + end + end + + context 'when the token is stale' do + let(:initial_token) do + described_class.new( + token_type: 'bearer', + access_token: 'initial42token', + expires_in: 7200 + ) + end + + before do + allow(initial_token).to receive(:stale?).and_return(true) + HelpScout.api.access_token = initial_token + end + + it 'calls create' do + expect(described_class).to receive(:create) + + subject + end + + it 'returns the newly created token' do + new_token = subject + + expect(new_token).to be_a(described_class) + expect(new_token.value).not_to eq(initial_token.value) + expect(new_token.value).to eq(access_token_params[:access_token]) + end + end + + context 'when the token is nil' do + before { HelpScout.api.access_token = nil } + + it 'calls create' do + expect(described_class).to receive(:create) + + subject + end + + it 'returns the newly created token' do + new_token = subject + + expect(new_token).to be_a(described_class) + expect(new_token.value).to eq(access_token_params[:access_token]) + end + end + end + + describe '#as_json' do + subject { access_token.as_json } + + it 'includes the value and expires_at attributes' do + expect(subject).to eq( + access_token: access_token.value, + expires_at: access_token.expires_at + ) + end + end + + describe '#expires_in' do + subject { access_token.expires_in } + + it { is_expected.to eq(access_token_params[:expires_in]) } + end + + describe '#expired?' do + subject { access_token.expired? } + + context 'when the token is likely to be expired' do + let(:access_token_params) { super().merge(expires_in: -100) } + + it { is_expected.to eq true } + end + + context 'when the token is not likely to be expired' do + it { is_expected.to eq false } + end + + context 'when the token has no expired_at time set' do + let(:access_token_params) { super().merge(expires_in: nil, expires_at: nil) } + + it { is_expected.to eq false } + end + end + + describe '#invalidate!' do + subject { access_token.invalidate! } + + context 'when no cache is configured' do + around { |example| with_config(token_cache: nil) { example.run } } + + it 'marks the access token as invalid' do + expect { subject }.to change { access_token.invalid? }.from(false).to(true) + end + + it 'does not attempt to clear the cache' do + expect_any_instance_of(HelpScout::API::AccessToken::Cache).not_to receive(:delete) + + subject + end + end + + context 'when a cache is configured' do + let(:token_cache) { double('cache') } + + around { |example| with_config(token_cache: token_cache) { example.run } } + + before { allow(token_cache).to receive(:delete) } + + it 'marks the access token as invalid' do + expect { subject }.to change { access_token.invalid? }.from(false).to(true) + end + + it 'attempts to clear the cache' do + expect(token_cache).to receive(:delete) + + subject + end + end + end + + describe '#value' do + subject { access_token.value } + + it { is_expected.to eq(access_token_params[:access_token]) } + end +end