diff --git a/.rubocop.yml b/.rubocop.yml index b6e412d..6672476 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -31,6 +31,9 @@ Layout/ParameterAlignment: Layout/SpaceInsideHashLiteralBraces: EnforcedStyle: no_space +Lint/MissingSuper: + Enabled: false + Metrics/ParameterLists: CountKeywordArgs: false @@ -40,9 +43,6 @@ Minitest/MultipleAssertions: Style/Alias: EnforcedStyle: prefer_alias_method -Style/Documentation: - Enabled: false - Style/FrozenStringLiteralComment: EnforcedStyle: never diff --git a/lib/x/bearer_token_authenticator.rb b/lib/x/bearer_token_authenticator.rb index 0ec87f7..9df3c78 100644 --- a/lib/x/bearer_token_authenticator.rb +++ b/lib/x/bearer_token_authenticator.rb @@ -18,7 +18,7 @@ class BearerTokenAuthenticator < Authenticator # @return [BearerTokenAuthenticator] a new instance # @example Create a new bearer token authenticator # authenticator = X::BearerTokenAuthenticator.new(bearer_token: "token") - def initialize(bearer_token:) # rubocop:disable Lint/MissingSuper + def initialize(bearer_token:) @bearer_token = bearer_token end diff --git a/lib/x/client.rb b/lib/x/client.rb index 31d0c1e..7a2c917 100644 --- a/lib/x/client.rb +++ b/lib/x/client.rb @@ -4,6 +4,7 @@ require_relative "client_credentials" require_relative "connection" require_relative "oauth_authenticator" +require_relative "oauth2_authenticator" require_relative "redirect_handler" require_relative "request_builder" require_relative "response_parser" @@ -28,12 +29,14 @@ class Client # @example Get or set the base URL # client.base_url = "https://api.twitter.com/1.1/" attr_accessor :base_url + # The default class for parsing JSON arrays # @api public # @return [Class] the default class for parsing JSON arrays # @example Get or set the default array class # client.default_array_class = Set attr_accessor :default_array_class + # The default class for parsing JSON objects # @api public # @return [Class] the default class for parsing JSON objects @@ -41,6 +44,13 @@ class Client # client.default_object_class = OpenStruct attr_accessor :default_object_class + # The authenticator for API requests + # @api public + # @return [Authenticator] the authenticator instance + # @example Check if the OAuth 2.0 token has expired + # client.authenticator.token_expired? + attr_reader :authenticator + def_delegators :@connection, :open_timeout, :read_timeout, :write_timeout, :proxy_url, :debug_output def_delegators :@connection, :open_timeout=, :read_timeout=, :write_timeout=, :proxy_url=, :debug_output= def_delegators :@redirect_handler, :max_redirects @@ -54,6 +64,9 @@ class Client # @param access_token [String, nil] the access token for OAuth authentication # @param access_token_secret [String, nil] the access token secret for OAuth 1.0a authentication # @param bearer_token [String, nil] the bearer token for authentication + # @param client_id [String, nil] the OAuth 2.0 client ID + # @param client_secret [String, nil] the OAuth 2.0 client secret + # @param refresh_token [String, nil] the OAuth 2.0 refresh token # @param base_url [String] the base URL for API requests # @param open_timeout [Integer] the timeout for opening connections in seconds # @param read_timeout [Integer] the timeout for reading responses in seconds @@ -69,7 +82,7 @@ class Client # @example Create a client with OAuth 1.0a authentication # client = X::Client.new(api_key: "key", api_key_secret: "secret", access_token: "token", access_token_secret: "token_secret") def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil, - bearer_token: nil, + bearer_token: nil, client_id: nil, client_secret: nil, refresh_token: nil, base_url: DEFAULT_BASE_URL, open_timeout: Connection::DEFAULT_OPEN_TIMEOUT, read_timeout: Connection::DEFAULT_READ_TIMEOUT, @@ -79,7 +92,8 @@ def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_toke default_array_class: DEFAULT_ARRAY_CLASS, default_object_class: DEFAULT_OBJECT_CLASS, max_redirects: RedirectHandler::DEFAULT_MAX_REDIRECTS) - initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:) + initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:, + client_id:, client_secret:, refresh_token:) initialize_authenticator @base_url = base_url @default_array_class = default_array_class @@ -137,9 +151,9 @@ def delete(endpoint, headers: {}, array_class: default_array_class, object_class # @return [Hash, Array, nil] the parsed response body def execute_request(http_method, endpoint, body: nil, headers: {}, array_class: default_array_class, object_class: default_object_class) uri = URI.join(base_url, endpoint) - request = @request_builder.build(http_method:, uri:, body:, headers:, authenticator: @authenticator) + request = @request_builder.build(http_method:, uri:, body:, headers:, authenticator:) response = @connection.perform(request:) - response = @redirect_handler.handle(response:, request:, base_url:, authenticator: @authenticator) + response = @redirect_handler.handle(response:, request:, base_url:, authenticator:) @response_parser.parse(response:, array_class:, object_class:) end end diff --git a/lib/x/client_credentials.rb b/lib/x/client_credentials.rb index 4478619..70ef6a0 100644 --- a/lib/x/client_credentials.rb +++ b/lib/x/client_credentials.rb @@ -8,24 +8,28 @@ module ClientCredentials # @example Get the API key # client.api_key attr_reader :api_key + # The API key secret for OAuth 1.0a authentication # @api public # @return [String, nil] the API key secret for OAuth 1.0a authentication # @example Get the API key secret # client.api_key_secret attr_reader :api_key_secret + # The access token for OAuth authentication # @api public # @return [String, nil] the access token for OAuth authentication # @example Get the access token # client.access_token attr_reader :access_token + # The access token secret for OAuth 1.0a authentication # @api public # @return [String, nil] the access token secret for OAuth 1.0a authentication # @example Get the access token secret # client.access_token_secret attr_reader :access_token_secret + # The bearer token for authentication # @api public # @return [String, nil] the bearer token for authentication @@ -33,6 +37,27 @@ module ClientCredentials # client.bearer_token attr_reader :bearer_token + # The OAuth 2.0 client ID + # @api public + # @return [String, nil] the OAuth 2.0 client ID + # @example Get the client ID + # client.client_id + attr_reader :client_id + + # The OAuth 2.0 client secret + # @api public + # @return [String, nil] the OAuth 2.0 client secret + # @example Get the client secret + # client.client_secret + attr_reader :client_secret + + # The OAuth 2.0 refresh token + # @api public + # @return [String, nil] the OAuth 2.0 refresh token + # @example Get the refresh token + # client.refresh_token + attr_reader :refresh_token + # Set the API key for OAuth 1.0a authentication # # @api public @@ -93,24 +118,64 @@ def bearer_token=(bearer_token) initialize_authenticator end + # Set the OAuth 2.0 client ID + # + # @api public + # @param client_id [String] the OAuth 2.0 client ID + # @return [void] + # @example Set the client ID + # client.client_id = "new_id" + def client_id=(client_id) + @client_id = client_id + initialize_authenticator + end + + # Set the OAuth 2.0 client secret + # + # @api public + # @param client_secret [String] the OAuth 2.0 client secret + # @return [void] + # @example Set the client secret + # client.client_secret = "new_secret" + def client_secret=(client_secret) + @client_secret = client_secret + initialize_authenticator + end + + # Set the OAuth 2.0 refresh token + # + # @api public + # @param refresh_token [String] the OAuth 2.0 refresh token + # @return [void] + # @example Set the refresh token + # client.refresh_token = "new_token" + def refresh_token=(refresh_token) + @refresh_token = refresh_token + initialize_authenticator + end + private # Initialize credential instance variables # @api private # @return [void] - def initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:) + def initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:, + client_id:, client_secret:, refresh_token:) @api_key = api_key @api_key_secret = api_key_secret @access_token = access_token @access_token_secret = access_token_secret @bearer_token = bearer_token + @client_id = client_id + @client_secret = client_secret + @refresh_token = refresh_token end # Initialize the appropriate authenticator based on available credentials # @api private # @return [Authenticator] the initialized authenticator def initialize_authenticator - @authenticator = oauth_authenticator || bearer_authenticator || @authenticator || Authenticator.new + @authenticator = oauth_authenticator || oauth2_authenticator || bearer_authenticator || @authenticator || Authenticator.new end # Build an OAuth 1.0a authenticator if credentials are available @@ -122,6 +187,15 @@ def oauth_authenticator OAuthAuthenticator.new(api_key:, api_key_secret:, access_token:, access_token_secret:) end + # Build an OAuth 2.0 authenticator if credentials are available + # @api private + # @return [OAuth2Authenticator, nil] the OAuth 2.0 authenticator or nil + def oauth2_authenticator + return unless client_id && client_secret && access_token && refresh_token + + OAuth2Authenticator.new(client_id:, client_secret:, access_token:, refresh_token:) + end + # Build a bearer token authenticator if credentials are available # @api private # @return [BearerTokenAuthenticator, nil] the bearer token authenticator or nil diff --git a/lib/x/oauth2_authenticator.rb b/lib/x/oauth2_authenticator.rb new file mode 100644 index 0000000..65247e1 --- /dev/null +++ b/lib/x/oauth2_authenticator.rb @@ -0,0 +1,169 @@ +require "base64" +require "json" +require "net/http" +require "uri" +require_relative "authenticator" +require_relative "connection" + +module X + # Handles OAuth 2.0 authentication with token refresh capability + # @api public + class OAuth2Authenticator < Authenticator + # Path for the OAuth 2.0 token endpoint + TOKEN_PATH = "/2/oauth2/token".freeze + # Host for token refresh requests + TOKEN_HOST = "api.x.com".freeze + # Grant type for token refresh + REFRESH_GRANT_TYPE = "refresh_token".freeze + # Buffer time in seconds to account for clock skew and network latency + EXPIRATION_BUFFER = 30 + + # The OAuth 2.0 client ID + # @api public + # @return [String] the client ID + # @example Get the client ID + # authenticator.client_id + attr_accessor :client_id + # The OAuth 2.0 client secret + # @api public + # @return [String] the client secret + # @example Get the client secret + # authenticator.client_secret + attr_accessor :client_secret + # The OAuth 2.0 access token + # @api public + # @return [String] the access token + # @example Get the access token + # authenticator.access_token + attr_accessor :access_token + # The OAuth 2.0 refresh token + # @api public + # @return [String] the refresh token + # @example Get the refresh token + # authenticator.refresh_token + attr_accessor :refresh_token + # The expiration time of the access token + # @api public + # @return [Time, nil] the expiration time + # @example Get the expiration time + # authenticator.expires_at + attr_accessor :expires_at + + # The connection for making token requests + # @api public + # @return [Connection] the connection instance + # @example Get the connection + # authenticator.connection + attr_accessor :connection + + # Initialize a new OAuth 2.0 authenticator + # + # @api public + # @param client_id [String] the OAuth 2.0 client ID + # @param client_secret [String] the OAuth 2.0 client secret + # @param access_token [String] the OAuth 2.0 access token + # @param refresh_token [String] the OAuth 2.0 refresh token + # @param expires_at [Time, nil] the expiration time of the access token + # @param connection [Connection] the connection for making token requests + # @return [OAuth2Authenticator] a new authenticator instance + # @example Create an authenticator + # authenticator = X::OAuth2Authenticator.new( + # client_id: "id", + # client_secret: "secret", + # access_token: "token", + # refresh_token: "refresh" + # ) + def initialize(client_id:, client_secret:, access_token:, refresh_token:, expires_at: nil, + connection: Connection.new) + @client_id = client_id + @client_secret = client_secret + @access_token = access_token + @refresh_token = refresh_token + @expires_at = expires_at + @connection = connection + end + + # Generate the authentication header + # + # @api public + # @param _request [Net::HTTPRequest, nil] the HTTP request (unused) + # @return [Hash{String => String}] the authentication header + # @example Get the header + # authenticator.header(request) + def header(_request) + {AUTHENTICATION_HEADER => "Bearer #{access_token}"} + end + + # Check if the access token has expired or will expire soon + # + # @api public + # @return [Boolean] true if the token has expired or will expire within the buffer period + # @example Check expiration + # authenticator.token_expired? + def token_expired? + return false if expires_at.nil? + + Time.now >= expires_at - EXPIRATION_BUFFER + end + + # Refresh the access token using the refresh token + # + # @api public + # @return [Hash{String => Object}] the token response + # @raise [Error] if token refresh fails + # @example Refresh the token + # authenticator.refresh_token! + def refresh_token! + response = send_token_request + handle_token_response(response) + end + + private + + # Send the token refresh request + # @api private + # @return [Net::HTTPResponse] the HTTP response + def send_token_request + request = build_token_request + connection.perform(request: request) + end + + # Build the token refresh request + # @api private + # @return [Net::HTTP::Post] the POST request + def build_token_request + uri = URI::HTTPS.build(host: TOKEN_HOST, path: TOKEN_PATH) + request = Net::HTTP::Post.new(uri) + request["Content-Type"] = "application/x-www-form-urlencoded" + request["Authorization"] = "Basic #{Base64.strict_encode64("#{client_id}:#{client_secret}")}" + request.body = URI.encode_www_form(grant_type: REFRESH_GRANT_TYPE, refresh_token: refresh_token) + request + end + + # Handle the token response + # @api private + # @param response [Net::HTTPResponse] the HTTP response + # @return [Hash{String => Object}] the parsed response body + # @raise [Error] if the response indicates an error + def handle_token_response(response) + body = JSON.parse(response.body) + rescue JSON::ParserError + raise Error, "Token refresh failed" + else + raise Error, body["error_description"] || body["error"] || "Token refresh failed" unless response.is_a?(Net::HTTPSuccess) + + update_tokens(body) + body + end + + # Update tokens from the response + # @api private + # @param token_response [Hash{String => Object}] the token response + # @return [void] + def update_tokens(token_response) + @access_token = token_response.fetch("access_token") + @refresh_token = token_response.fetch("refresh_token") if token_response.key?("refresh_token") + @expires_at = Time.now + token_response.fetch("expires_in") if token_response.key?("expires_in") + end + end +end diff --git a/lib/x/oauth_authenticator.rb b/lib/x/oauth_authenticator.rb index a7629f4..c4e6050 100644 --- a/lib/x/oauth_authenticator.rb +++ b/lib/x/oauth_authenticator.rb @@ -60,7 +60,7 @@ class OAuthAuthenticator < Authenticator # access_token: "token", # access_token_secret: "token_secret" # ) - def initialize(api_key:, api_key_secret:, access_token:, access_token_secret:) # rubocop:disable Lint/MissingSuper + def initialize(api_key:, api_key_secret:, access_token:, access_token_secret:) @api_key = api_key @api_key_secret = api_key_secret @access_token = access_token diff --git a/sig/x.rbs b/sig/x.rbs index b977128..90a7f23 100644 --- a/sig/x.rbs +++ b/sig/x.rbs @@ -208,22 +208,53 @@ module X def json?: (Net::HTTPResponse response) -> bool end + class OAuth2Authenticator < Authenticator + TOKEN_PATH: String + TOKEN_HOST: String + REFRESH_GRANT_TYPE: String + EXPIRATION_BUFFER: Integer + + attr_accessor client_id: String + attr_accessor client_secret: String + attr_accessor access_token: String + attr_accessor refresh_token: String + attr_accessor expires_at: Time? + attr_accessor connection: Connection + def initialize: (client_id: String, client_secret: String, access_token: String, refresh_token: String, ?expires_at: Time?, ?connection: Connection) -> void + def header: (Net::HTTPRequest? request) -> Hash[String, String] + def token_expired?: -> bool + def refresh_token!: -> Hash[String, untyped] + + private + def send_token_request: -> Net::HTTPResponse + def build_token_request: -> Net::HTTP::Post + def handle_token_response: (Net::HTTPResponse response) -> Hash[String, untyped] + def update_tokens: (Hash[String, untyped] token_response) -> void + end + module ClientCredentials attr_reader api_key: String? attr_reader api_key_secret: String? attr_reader access_token: String? attr_reader access_token_secret: String? attr_reader bearer_token: String? + attr_reader client_id: String? + attr_reader client_secret: String? + attr_reader refresh_token: String? def api_key=: (String api_key) -> void def api_key_secret=: (String api_key_secret) -> void def access_token=: (String access_token) -> void def access_token_secret=: (String access_token_secret) -> void def bearer_token=: (String bearer_token) -> void + def client_id=: (String client_id) -> void + def client_secret=: (String client_secret) -> void + def refresh_token=: (String refresh_token) -> void private - def initialize_credentials: (api_key: String?, api_key_secret: String?, access_token: String?, access_token_secret: String?, bearer_token: String?) -> void - def initialize_authenticator: -> (Authenticator | BearerTokenAuthenticator | OAuthAuthenticator) + def initialize_credentials: (api_key: String?, api_key_secret: String?, access_token: String?, access_token_secret: String?, bearer_token: String?, client_id: String?, client_secret: String?, refresh_token: String?) -> void + def initialize_authenticator: -> (Authenticator | BearerTokenAuthenticator | OAuthAuthenticator | OAuth2Authenticator) def oauth_authenticator: -> OAuthAuthenticator? + def oauth2_authenticator: -> OAuth2Authenticator? def bearer_authenticator: -> BearerTokenAuthenticator? end @@ -234,7 +265,7 @@ module X DEFAULT_ARRAY_CLASS: singleton(Array) DEFAULT_OBJECT_CLASS: singleton(Hash) extend Forwardable - @authenticator: Authenticator | BearerTokenAuthenticator | OAuthAuthenticator + @authenticator: Authenticator | BearerTokenAuthenticator | OAuthAuthenticator | OAuth2Authenticator @connection: Connection @request_builder: RequestBuilder @redirect_handler: RedirectHandler @@ -243,7 +274,8 @@ module X attr_accessor base_url: String attr_accessor default_array_class: singleton(Array) attr_accessor default_object_class: singleton(Hash) - def initialize: (?api_key: String?, ?api_key_secret: String?, ?access_token: String?, ?access_token_secret: String?, ?bearer_token: String?, ?base_url: String, ?open_timeout: Integer, ?read_timeout: Integer, ?write_timeout: Integer, ?debug_output: untyped, ?proxy_url: String?, ?default_array_class: singleton(Array), ?default_object_class: singleton(Hash), ?max_redirects: Integer) -> void + attr_reader authenticator: Authenticator | BearerTokenAuthenticator | OAuthAuthenticator | OAuth2Authenticator + def initialize: (?api_key: String?, ?api_key_secret: String?, ?access_token: String?, ?access_token_secret: String?, ?bearer_token: String?, ?client_id: String?, ?client_secret: String?, ?refresh_token: String?, ?base_url: String, ?open_timeout: Integer, ?read_timeout: Integer, ?write_timeout: Integer, ?debug_output: untyped, ?proxy_url: String?, ?default_array_class: singleton(Array), ?default_object_class: singleton(Hash), ?max_redirects: Integer) -> void def get: (String endpoint, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped def post: (String endpoint, ?String? body, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped def put: (String endpoint, ?String? body, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped diff --git a/test/test_helper.rb b/test/test_helper.rb index 8db7333..cf5fa79 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -23,6 +23,9 @@ TEST_OAUTH_NONCE = "TEST_OAUTH_NONCE".freeze TEST_OAUTH_TIMESTAMP = Time.utc(1983, 11, 24).to_i.to_s TEST_MEDIA_ID = "TEST_MEDIA_ID".freeze +TEST_CLIENT_ID = "TEST_CLIENT_ID".freeze +TEST_CLIENT_SECRET = "TEST_CLIENT_SECRET".freeze +TEST_REFRESH_TOKEN = "TEST_REFRESH_TOKEN".freeze def test_oauth_credentials { @@ -33,6 +36,15 @@ def test_oauth_credentials } end +def test_oauth2_credentials + { + client_id: TEST_CLIENT_ID, + client_secret: TEST_CLIENT_SECRET, + access_token: TEST_ACCESS_TOKEN, + refresh_token: TEST_REFRESH_TOKEN + } +end + def test_oauth_params { "oauth_consumer_key" => TEST_API_KEY, diff --git a/test/x/client_initialization_test.rb b/test/x/client_initialization_test.rb index a6c23d6..d6afceb 100644 --- a/test/x/client_initialization_test.rb +++ b/test/x/client_initialization_test.rb @@ -2,17 +2,13 @@ require_relative "../test_helper" module X - class ClientInitializationTest < Minitest::Test + class ClientOAuthInitializationTest < Minitest::Test cover Client - def setup - @client = Client.new - end - def test_initialize_oauth_credentials client = Client.new(**test_oauth_credentials) - authenticator = client.instance_variable_get(:@authenticator) + authenticator = client.authenticator assert_instance_of OAuthAuthenticator, authenticator assert_equal TEST_API_KEY, authenticator.api_key @@ -25,57 +21,144 @@ def test_missing_oauth_credentials test_oauth_credentials.each_key do |missing_credential| client = Client.new(**test_oauth_credentials.except(missing_credential)) - assert_instance_of Authenticator, client.instance_variable_get(:@authenticator) + assert_instance_of Authenticator, client.authenticator end end def test_setting_oauth_credentials + client = Client.new test_oauth_credentials.each do |credential, value| - @client.public_send(:"#{credential}=", value) + client.public_send(:"#{credential}=", value) - assert_equal value, @client.public_send(credential) + assert_equal value, client.public_send(credential) end - assert_instance_of OAuthAuthenticator, @client.instance_variable_get(:@authenticator) + assert_instance_of OAuthAuthenticator, client.authenticator end def test_setting_oauth_credentials_reinitializes_authenticator + client = Client.new test_oauth_credentials.each do |credential, value| initialize_authenticator_called = false - @client.stub :initialize_authenticator, -> { initialize_authenticator_called = true } do - @client.public_send(:"#{credential}=", value) + client.stub :initialize_authenticator, -> { initialize_authenticator_called = true } do + client.public_send(:"#{credential}=", value) end - assert_equal value, @client.public_send(credential) assert initialize_authenticator_called, "Expected initialize_authenticator to be called" end end + end - def test_setting_bearer_token - @client.bearer_token = "bearer_token" + class ClientOAuth2InitializationTest < Minitest::Test + cover Client + + def test_initialize_oauth2_credentials + client = Client.new(**test_oauth2_credentials) + + authenticator = client.authenticator + + assert_instance_of OAuth2Authenticator, authenticator + assert_equal TEST_CLIENT_ID, authenticator.client_id + assert_equal TEST_CLIENT_SECRET, authenticator.client_secret + assert_equal TEST_ACCESS_TOKEN, authenticator.access_token + assert_equal TEST_REFRESH_TOKEN, authenticator.refresh_token + end + + def test_missing_oauth2_credentials + test_oauth2_credentials.each_key do |missing_credential| + client = Client.new(**test_oauth2_credentials.except(missing_credential)) + + assert_instance_of Authenticator, client.authenticator + end + end + + def test_setting_oauth2_credentials + client = Client.new + test_oauth2_credentials.each do |credential, value| + client.public_send(:"#{credential}=", value) + + assert_equal value, client.public_send(credential) + end + + assert_instance_of OAuth2Authenticator, client.authenticator + end + + def test_setting_oauth2_credentials_reinitializes_authenticator + client = Client.new + test_oauth2_credentials.each do |credential, value| + initialize_authenticator_called = false + client.stub :initialize_authenticator, -> { initialize_authenticator_called = true } do + client.public_send(:"#{credential}=", value) + end + + assert initialize_authenticator_called, "Expected initialize_authenticator to be called" + end + end + end - authenticator = @client.instance_variable_get(:@authenticator) + class ClientAuthenticatorPrecedenceTest < Minitest::Test + cover Client + + def test_oauth1_takes_precedence_over_oauth2 + client = Client.new(**test_oauth_credentials, client_id: TEST_CLIENT_ID, client_secret: TEST_CLIENT_SECRET, + refresh_token: TEST_REFRESH_TOKEN) + + assert_instance_of OAuthAuthenticator, client.authenticator + end + + def test_oauth2_takes_precedence_over_bearer_token + client = Client.new(**test_oauth2_credentials, bearer_token: TEST_BEARER_TOKEN) + + assert_instance_of OAuth2Authenticator, client.authenticator + end - assert_equal "bearer_token", @client.bearer_token - assert_instance_of BearerTokenAuthenticator, authenticator + def test_setting_bearer_token + client = Client.new + client.bearer_token = "bearer_token" + + assert_equal "bearer_token", client.bearer_token + assert_instance_of BearerTokenAuthenticator, client.authenticator end def test_authenticator_remains_unchanged_if_no_new_credentials - initial_authenticator = @client.instance_variable_get(:@authenticator) + client = Client.new + initial_authenticator = client.authenticator - @client.api_key = nil - @client.api_key_secret = nil - @client.access_token = nil - @client.access_token_secret = nil - @client.bearer_token = nil + client.api_key = nil + client.bearer_token = nil - new_authenticator = @client.instance_variable_get(:@authenticator) + assert_equal initial_authenticator, client.authenticator + end - assert_equal initial_authenticator, new_authenticator + def test_initialize_authenticator_uses_instance_variable_not_accessor + client = Client.new(**test_oauth_credentials) + original_authenticator = client.authenticator + clear_all_credentials(client) + + # If code uses accessor (nil), falls through to Authenticator.new; if @authenticator, preserves original + client.stub(:authenticator, nil) { client.send(:initialize_authenticator) } + + assert_equal original_authenticator, client.authenticator end + private + + def clear_all_credentials(client) + %i[@api_key @api_key_secret @access_token @access_token_secret].each do |var| + client.instance_variable_set(var, nil) + end + %i[@bearer_token @client_id @client_secret @refresh_token].each do |var| + client.instance_variable_set(var, nil) + end + end + end + + class ClientConnectionOptionsTest < Minitest::Test + cover Client + def test_initialize_with_default_connection_options - connection = @client.instance_variable_get(:@connection) + client = Client.new + connection = client.instance_variable_get(:@connection) assert_equal Connection::DEFAULT_OPEN_TIMEOUT, connection.open_timeout assert_equal Connection::DEFAULT_READ_TIMEOUT, connection.read_timeout @@ -85,8 +168,8 @@ def test_initialize_with_default_connection_options end def test_initialize_connection_options - client = Client.new(open_timeout: 10, read_timeout: 20, write_timeout: 30, debug_output: $stderr, proxy_url: "https://user:pass@proxy.com:42") - + client = Client.new(open_timeout: 10, read_timeout: 20, write_timeout: 30, + debug_output: $stderr, proxy_url: "https://user:pass@proxy.com:42") connection = client.instance_variable_get(:@connection) assert_equal 10, connection.open_timeout @@ -95,36 +178,37 @@ def test_initialize_connection_options assert_equal $stderr, connection.debug_output assert_equal "https://user:pass@proxy.com:42", connection.proxy_url end + end + + class ClientDefaultsTest < Minitest::Test + cover Client def test_defaults - @client = Client.new + client = Client.new - assert_equal "https://api.twitter.com/2/", @client.base_url - assert_equal 10, @client.max_redirects - assert_equal Hash, @client.default_object_class - assert_equal Array, @client.default_array_class + assert_equal "https://api.twitter.com/2/", client.base_url + assert_equal 10, client.max_redirects + assert_equal Hash, client.default_object_class + assert_equal Array, client.default_array_class end def test_overwrite_defaults - @client = Client.new(base_url: "https://api.twitter.com/1.1/", max_redirects: 5, default_object_class: OpenStruct, - default_array_class: Set) + client = Client.new(base_url: "https://api.twitter.com/1.1/", max_redirects: 5, + default_object_class: OpenStruct, default_array_class: Set) - assert_equal "https://api.twitter.com/1.1/", @client.base_url - assert_equal 5, @client.max_redirects - assert_equal OpenStruct, @client.default_object_class - assert_equal Set, @client.default_array_class + assert_equal "https://api.twitter.com/1.1/", client.base_url + assert_equal 5, client.max_redirects + assert_equal OpenStruct, client.default_object_class + assert_equal Set, client.default_array_class end def test_passes_options_to_redirect_handler client = Client.new(max_redirects: 5) - connection = client.instance_variable_get(:@connection) - request_builder = client.instance_variable_get(:@request_builder) redirect_handler = client.instance_variable_get(:@redirect_handler) - max_redirects = redirect_handler.instance_variable_get(:@max_redirects) - assert_equal connection, redirect_handler.connection - assert_equal request_builder, redirect_handler.request_builder - assert_equal 5, max_redirects + assert_equal client.instance_variable_get(:@connection), redirect_handler.connection + assert_equal client.instance_variable_get(:@request_builder), redirect_handler.request_builder + assert_equal 5, redirect_handler.instance_variable_get(:@max_redirects) end end end diff --git a/test/x/oauth2_authenticator_test.rb b/test/x/oauth2_authenticator_test.rb new file mode 100644 index 0000000..57e10d9 --- /dev/null +++ b/test/x/oauth2_authenticator_test.rb @@ -0,0 +1,232 @@ +require_relative "../test_helper" + +module X + TOKEN_URL = "https://#{OAuth2Authenticator::TOKEN_HOST}#{OAuth2Authenticator::TOKEN_PATH}".freeze + + class OAuth2AuthenticatorInitializationTest < Minitest::Test + cover OAuth2Authenticator + + def test_initialize_with_required_credentials + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + assert_equal TEST_CLIENT_ID, authenticator.client_id + assert_equal TEST_CLIENT_SECRET, authenticator.client_secret + assert_equal TEST_ACCESS_TOKEN, authenticator.access_token + assert_equal TEST_REFRESH_TOKEN, authenticator.refresh_token + end + + def test_initialize_with_expires_at + expires_at = Time.now + 7200 + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: expires_at) + + assert_equal expires_at, authenticator.expires_at + end + + def test_initialize_without_expires_at + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + assert_nil authenticator.expires_at + end + end + + class OAuth2AuthenticatorHeaderTest < Minitest::Test + cover OAuth2Authenticator + + def test_header_returns_bearer_token + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + header = authenticator.header(nil) + + assert_equal({"Authorization" => "Bearer #{TEST_ACCESS_TOKEN}"}, header) + end + end + + class OAuth2AuthenticatorTokenExpirationTest < Minitest::Test + cover OAuth2Authenticator + + def test_token_expired_returns_false_when_no_expires_at + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + refute_predicate authenticator, :token_expired? + end + + def test_token_expired_returns_false_when_not_expired + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now + 3600) + + refute_predicate authenticator, :token_expired? + end + + def test_token_expired_returns_true_when_expired + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: Time.now - 1) + + assert_predicate authenticator, :token_expired? + end + + def test_token_expired_returns_true_at_exact_expiration_time + now = Time.now + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: now) + + Time.stub :now, now do + assert_predicate authenticator, :token_expired? + end + end + + def test_token_expired_returns_true_at_buffer_boundary + now = Time.now + expires_at = now + OAuth2Authenticator::EXPIRATION_BUFFER + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: expires_at) + + Time.stub :now, now do + assert_predicate authenticator, :token_expired? + end + end + + def test_token_expired_returns_true_within_buffer + now = Time.now + expires_at = now + (OAuth2Authenticator::EXPIRATION_BUFFER - 1) + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: expires_at) + + Time.stub :now, now do + assert_predicate authenticator, :token_expired? + end + end + + def test_token_expired_returns_false_just_outside_buffer + now = Time.now + expires_at = now + OAuth2Authenticator::EXPIRATION_BUFFER + 1 + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials, expires_at: expires_at) + + Time.stub :now, now do + refute_predicate authenticator, :token_expired? + end + end + end + + class OAuth2AuthenticatorRefreshTokenTest < Minitest::Test + cover OAuth2Authenticator + + def test_refresh_token_sends_correct_content_type + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL) + .with(headers: {"Content-Type" => "application/x-www-form-urlencoded"}) + .to_return(status: 200, body: {access_token: "new"}.to_json) + + authenticator.refresh_token! + end + + def test_refresh_token_sends_basic_auth_header + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + expected_auth = "Basic #{Base64.strict_encode64("#{TEST_CLIENT_ID}:#{TEST_CLIENT_SECRET}")}" + stub_request(:post, TOKEN_URL) + .with(headers: {"Authorization" => expected_auth}) + .to_return(status: 200, body: {access_token: "new"}.to_json) + + authenticator.refresh_token! + end + + def test_refresh_token_sends_correct_request_body + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + expected_body = "grant_type=refresh_token&refresh_token=#{TEST_REFRESH_TOKEN}" + stub_request(:post, TOKEN_URL) + .with(body: expected_body) + .to_return(status: 200, body: {access_token: "new"}.to_json) + + authenticator.refresh_token! + end + + def test_refresh_token_updates_access_token + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL) + .to_return(status: 200, body: {access_token: "NEW_ACCESS_TOKEN"}.to_json) + + authenticator.refresh_token! + + assert_equal "NEW_ACCESS_TOKEN", authenticator.access_token + end + + def test_refresh_token_updates_refresh_token + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL) + .to_return(status: 200, body: {access_token: "new", refresh_token: "NEW_REFRESH"}.to_json) + + authenticator.refresh_token! + + assert_equal "NEW_REFRESH", authenticator.refresh_token + end + + def test_refresh_token_returns_response_body + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL) + .to_return(status: 200, body: {access_token: "new"}.to_json) + + result = authenticator.refresh_token! + + assert_equal "new", result["access_token"] + end + + def test_refresh_token_updates_expires_at + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL) + .to_return(status: 200, body: {access_token: "new", expires_in: 7200}.to_json) + + before_refresh = Time.now + authenticator.refresh_token! + + assert_operator authenticator.expires_at, :>=, before_refresh + 7200 + end + + def test_refresh_token_keeps_old_refresh_token_when_not_returned + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + stub_request(:post, TOKEN_URL) + .to_return(status: 200, body: {access_token: "new"}.to_json) + + authenticator.refresh_token! + + assert_equal TEST_REFRESH_TOKEN, authenticator.refresh_token + end + end + + class OAuth2AuthenticatorRefreshTokenErrorTest < Minitest::Test + cover OAuth2Authenticator + + def test_refresh_token_raises_on_error_with_description + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + stub_request(:post, TOKEN_URL) + .to_return(status: 400, body: {error: "invalid_grant", error_description: "Token expired"}.to_json) + + error = assert_raises(Error) { authenticator.refresh_token! } + assert_equal "Token expired", error.message + end + + def test_refresh_token_raises_on_error_without_description + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + stub_request(:post, TOKEN_URL) + .to_return(status: 400, body: {error: "invalid_grant"}.to_json) + + error = assert_raises(Error) { authenticator.refresh_token! } + assert_equal "invalid_grant", error.message + end + + def test_refresh_token_raises_on_error_with_default_message + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + stub_request(:post, TOKEN_URL) + .to_return(status: 500, body: {}.to_json) + + error = assert_raises(Error) { authenticator.refresh_token! } + assert_equal "Token refresh failed", error.message + end + + def test_refresh_token_raises_on_invalid_json_response + authenticator = OAuth2Authenticator.new(**test_oauth2_credentials) + + stub_request(:post, TOKEN_URL) + .to_return(status: 500, body: "Internal Server Error") + + error = assert_raises(Error) { authenticator.refresh_token! } + assert_equal "Token refresh failed", error.message + end + end +end