From b75edbc83bc3f05a28e6118b166248fe29d8b153 Mon Sep 17 00:00:00 2001 From: MudassarTariq Date: Wed, 4 Nov 2026 15:03:16 +0500 Subject: [PATCH 1/2] Update error handling, Arabic support and version to 2.0.2 --- CHANGELOG.md | 8 + README.md | 34 ++-- lib/ipgeolocation_sdk/client.rb | 50 ++++-- lib/ipgeolocation_sdk/enums.rb | 3 +- lib/ipgeolocation_sdk/errors.rb | 55 +++++++ lib/ipgeolocation_sdk/serde.rb | 21 ++- lib/ipgeolocation_sdk/version.rb | 2 +- spec/ip_location/client_http_spec.rb | 81 ++++++++++ spec/ip_location/error_redaction_spec.rb | 198 +++++++++++++++++++++++ spec/ip_location/public_api_spec.rb | 2 +- spec/ip_location/request_models_spec.rb | 14 ++ 11 files changed, 419 insertions(+), 49 deletions(-) create mode 100644 spec/ip_location/client_http_spec.rb create mode 100644 spec/ip_location/error_redaction_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4589983..920d4b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.0.2 + +- Hide API keys in request errors and typed bulk error messages. +- Preserve HTTP error types and status codes when error responses are incomplete or invalid. +- Add Arabic (`ar`) for single and bulk lookups. + +Existing public methods and Ruby 2.7+ support remain unchanged. + ## 2.0.1 - Mask API keys in configuration serialization and client/config inspection. diff --git a/README.md b/README.md index 79e2556..32a7ca9 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ You can also pass `LookupIpGeolocationRequest` and `BulkLookupIpGeolocationReque | Supported Endpoints | `/v3/ipgeo`, `/v3/ipgeo-bulk` | | Supported Inputs | IPv4, IPv6, domain | | Main Data Returned | Geolocation, company, ASN, time zone, network, hostname, abuse, user-agent, currency, security | -| Authentication | API key, request-origin auth for `/v3/ipgeo` only | +| Authentication | API key | | Response Formats | Structured JSON, raw JSON, raw XML | | Bulk Limit | Up to 50,000 IPs or domains per request | | Transport | `Net::HTTP` | @@ -101,7 +101,7 @@ To use most SDK features, create or access your IPGeolocation.io account and cop 4. Open your dashboard: 5. Copy an API key from the `API Keys` section -For server-side code, keep the API key in an environment variable or secret manager. For browser-based single lookups on paid plans, use request-origin auth instead of exposing an API key in frontend code. +Keep the API key in an environment variable or secret manager. ## Authentication @@ -113,21 +113,7 @@ client = IpgeolocationSdk::IpGeolocationClient.new( ) ``` -### Request-Origin Auth - -```ruby -client = IpgeolocationSdk::IpGeolocationClient.new( - request_origin: "https://app.example.com" -) -``` - -`request_origin` must be an absolute `http` or `https` origin with no path, query string, fragment, or userinfo. - -> [!IMPORTANT] -> Request-origin auth does not work with `/v3/ipgeo-bulk`. Bulk lookup always requires `api_key`. - -> [!NOTE] -> If you set both `api_key` and `request_origin`, single lookup still uses the API key. The API key is sent as the `apiKey` query parameter, so avoid logging full request URLs. +The API key is sent as the `apiKey` query parameter, so avoid logging full request URLs. ## Plan Behavior @@ -139,7 +125,6 @@ Feature availability depends on your plan and request parameters. | Domain lookup | Not supported | Supported | | Bulk lookup | Not supported | Supported | | Non-English `lang` | Not supported | Supported | -| Request-origin auth | Not supported | Supported for `/v3/ipgeo` only | | Optional modules via `include` | Not supported | Supported | | `include: ["*"]` | Base response only | All plan-available modules | @@ -149,8 +134,7 @@ Paid plans still need `include` for optional modules. `fields` and `excludes` on | Field | Type | Default | Notes | |-------|------|---------|-------| -| `api_key` | `String` | unset | Required for bulk lookup. Optional for single lookup if `request_origin` is set. | -| `request_origin` | `String` | unset | Must be an absolute `http` or `https` origin. | +| `api_key` | `String` | unset | Your IPGeolocation.io API key. | | `base_url` | `String` | `https://api.ipgeolocation.io` | Override the API base URL. | | `connect_timeout` | `Numeric` | `10.0` | Time to open the connection, in seconds. | | `read_timeout` | `Numeric` | `30.0` | Time to wait while reading the response body, in seconds. | @@ -176,7 +160,7 @@ The client constructor accepts either an `IpGeolocationClientConfig` or a hash w |-------|------------|-------| | `ip` | Single lookup | IPv4, IPv6, or domain. Omit it for caller IP lookup. | | `ips` | Bulk lookup | Array of 1 to 50,000 IPs or domains. | -| `lang` | Single and bulk | One of `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`. | +| `lang` | Single and bulk | One of `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`, `ar` (Arabic). | | `include` | Single and bulk | Array of module names such as `security`, `abuse`, `user_agent`, `hostname`, `liveHostname`, `hostnameFallbackLive`, `geo_accuracy`, `dma_code`, or `*`. | | `fields` | Single and bulk | Array of field paths to keep, for example `["location.country_name", "security.threat_score"]`. | | `excludes` | Single and bulk | Array of field paths to remove from the response. | @@ -335,6 +319,8 @@ Compact mode omits `nil` fields. Full mode keeps them. When serializing a client ## Errors +If a request error includes your configured API key, the SDK replaces it with `[REDACTED]`. This also applies to errors in typed bulk results. SDK error types and HTTP status codes stay the same. Some error details may be shortened to keep the key hidden. + The SDK raises these exception classes: - `IpgeolocationSdk::ValidationError` @@ -367,10 +353,8 @@ end ## Troubleshooting -- `bulk lookup requires api_key in client config` - Bulk lookup does not support request-origin auth on its own. -- `single lookup requires api_key or request_origin in client config` - Set at least one authentication option before calling the single lookup methods. +- Missing API key + Set `api_key` in the client configuration before making a lookup. - `XML output is not supported by typed methods` Use `lookup_ip_geolocation_raw` or `bulk_lookup_ip_geolocation_raw` for XML output. - `client is closed` diff --git a/lib/ipgeolocation_sdk/client.rb b/lib/ipgeolocation_sdk/client.rb index b46c089..39208dc 100644 --- a/lib/ipgeolocation_sdk/client.rb +++ b/lib/ipgeolocation_sdk/client.rb @@ -33,61 +33,85 @@ def inspect end def lookup_ip_geolocation(request = nil) + api_key = @config.api_key&.dup + redactor = ErrorRedactor.new(api_key) ensure_open typed_request = to_lookup_request(request) validate_lookup_request_base Serde.validate_json_output(typed_request.output) - response, duration_ms = execute_with_metrics(build_lookup_http_request(typed_request)) - raise Serde.to_api_error(response.status_code, response.body) unless success_status?(response.status_code) + response, duration_ms = execute_with_metrics(build_lookup_http_request(typed_request, api_key)) + raise Serde.to_api_error(response.status_code, response.body, redactor) unless success_status?(response.status_code) ApiResponse.new( data: Serde.parse_single_lookup(response.body), metadata: Serde.to_metadata(response.status_code, duration_ms, response.headers) ) + rescue StandardError => error + raise if api_key.nil? + + raise redactor.copy(error), cause: nil end def lookup_ip_geolocation_raw(request = nil) + api_key = @config.api_key&.dup + redactor = ErrorRedactor.new(api_key) ensure_open typed_request = to_lookup_request(request) validate_lookup_request_base - response, duration_ms = execute_with_metrics(build_lookup_http_request(typed_request)) - raise Serde.to_api_error(response.status_code, response.body) unless success_status?(response.status_code) + response, duration_ms = execute_with_metrics(build_lookup_http_request(typed_request, api_key)) + raise Serde.to_api_error(response.status_code, response.body, redactor) unless success_status?(response.status_code) ApiResponse.new( data: response.body, metadata: Serde.to_metadata(response.status_code, duration_ms, response.headers) ) + rescue StandardError => error + raise if api_key.nil? + + raise redactor.copy(error), cause: nil end def bulk_lookup_ip_geolocation(request) + api_key = @config.api_key&.dup + redactor = ErrorRedactor.new(api_key) ensure_open typed_request = to_bulk_request(request) validate_bulk_request_base Serde.validate_json_output(typed_request.output) - response, duration_ms = execute_with_metrics(build_bulk_http_request(typed_request)) - raise Serde.to_api_error(response.status_code, response.body) unless success_status?(response.status_code) + response, duration_ms = execute_with_metrics(build_bulk_http_request(typed_request, api_key)) + raise Serde.to_api_error(response.status_code, response.body, redactor) unless success_status?(response.status_code) ApiResponse.new( - data: Serde.parse_bulk_lookup(response.body), + data: Serde.parse_bulk_lookup(response.body, redactor), metadata: Serde.to_metadata(response.status_code, duration_ms, response.headers) ) + rescue StandardError => error + raise if api_key.nil? + + raise redactor.copy(error), cause: nil end def bulk_lookup_ip_geolocation_raw(request) + api_key = @config.api_key&.dup + redactor = ErrorRedactor.new(api_key) ensure_open typed_request = to_bulk_request(request) validate_bulk_request_base - response, duration_ms = execute_with_metrics(build_bulk_http_request(typed_request)) - raise Serde.to_api_error(response.status_code, response.body) unless success_status?(response.status_code) + response, duration_ms = execute_with_metrics(build_bulk_http_request(typed_request, api_key)) + raise Serde.to_api_error(response.status_code, response.body, redactor) unless success_status?(response.status_code) ApiResponse.new( data: response.body, metadata: Serde.to_metadata(response.status_code, duration_ms, response.headers) ) + rescue StandardError => error + raise if api_key.nil? + + raise redactor.copy(error), cause: nil end def close @@ -140,10 +164,10 @@ def to_bulk_request(request) end end - def build_lookup_http_request(request) + def build_lookup_http_request(request, api_key) output = request.output || ResponseFormat::JSON query = Serde.build_query( - "apiKey" => @config.api_key, + "apiKey" => api_key, "ip" => request.ip, "lang" => request.lang, "include" => request.include.empty? ? nil : request.include.join(","), @@ -175,10 +199,10 @@ def build_lookup_http_request(request) ) end - def build_bulk_http_request(request) + def build_bulk_http_request(request, api_key) output = request.output || ResponseFormat::JSON query = Serde.build_query( - "apiKey" => @config.api_key, + "apiKey" => api_key, "lang" => request.lang, "include" => request.include.empty? ? nil : request.include.join(","), "fields" => request.fields.empty? ? nil : request.fields.join(","), diff --git a/lib/ipgeolocation_sdk/enums.rb b/lib/ipgeolocation_sdk/enums.rb index 9881414..6031ecf 100644 --- a/lib/ipgeolocation_sdk/enums.rb +++ b/lib/ipgeolocation_sdk/enums.rb @@ -14,9 +14,10 @@ module Language KO = "ko" FA = "fa" PT = "pt" + AR = "ar" VALUES = [ - EN, DE, RU, JA, FR, CN, ES, CS, IT, KO, FA, PT + EN, DE, RU, JA, FR, CN, ES, CS, IT, KO, FA, PT, AR ].freeze module_function diff --git a/lib/ipgeolocation_sdk/errors.rb b/lib/ipgeolocation_sdk/errors.rb index 11f5ee1..b237c1f 100644 --- a/lib/ipgeolocation_sdk/errors.rb +++ b/lib/ipgeolocation_sdk/errors.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "cgi" +require "json" + module IpgeolocationSdk class IpGeolocationError < StandardError attr_reader :cause @@ -35,4 +38,56 @@ class LockedError < ApiError; end class RateLimitError < ApiError; end class ClientClosedRequestError < ApiError; end class ServerError < ApiError; end + + class ErrorRedactor + def initialize(api_key) + return if api_key.nil? || api_key.empty? + + key = api_key.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) + escaped = CGI.escape(key) + forms = [key, escaped, escaped.gsub("+", "%20"), CGI.escapeHTML(key), + JSON.generate(key)[1...-1], JSON.generate(key, ascii_only: true)[1...-1]] + patterns = forms.uniq.sort_by { |form| -form.length }.map do |form| + Regexp.escape(form).gsub(/%[0-9A-F]{2}/) { |escape| "(?i:#{escape})" } + end + @pattern = Regexp.new(patterns.join("|")) + end + + def text(value) + return value if value.nil? || @pattern.nil? + + value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).gsub(@pattern, "[REDACTED]") + end + + def copy(error, depth = 0) + return nil if error.nil? || depth >= 8 + + message = error.is_a?(JSON::ParserError) ? "Invalid JSON response" : text(error.message) + cause = copy(error.cause, depth + 1) + sanitized = + begin + if error.is_a?(ApiError) + error.class.new(message, error.status_code, text(error.api_message)) + elsif error.is_a?(IpGeolocationError) + error.class.new(message, cause: cause) + else + error.class.new(message) + end + rescue StandardError + if error.is_a?(ApiError) + ApiError.new(message, error.status_code, text(error.api_message)) + elsif error.is_a?(IpGeolocationError) + klass = [RequestTimeoutError, TransportError, ValidationError, SerializationError, IpGeolocationError].find do |type| + error.is_a?(type) + end + klass.new(message, cause: cause) + else + StandardError.new(message) + end + end + sanitized.set_backtrace(error.backtrace&.map { |line| text(line) }) + sanitized + end + end + private_constant :ErrorRedactor end diff --git a/lib/ipgeolocation_sdk/serde.rb b/lib/ipgeolocation_sdk/serde.rb index 4c5694d..94d5ddc 100644 --- a/lib/ipgeolocation_sdk/serde.rb +++ b/lib/ipgeolocation_sdk/serde.rb @@ -14,7 +14,7 @@ def parse_single_lookup(body) parse_ip_geolocation_response(payload) end - def parse_bulk_lookup(body) + def parse_bulk_lookup(body, redactor = nil) payload = parse_json(body, "Failed to deserialize bulk lookup response") unless payload.is_a?(Array) raise SerializationError, "Failed to deserialize bulk response: expected an array payload" @@ -22,8 +22,10 @@ def parse_bulk_lookup(body) payload.map do |item| if bulk_error_item?(item) + message = string_or_nil(item["message"]) + message = redactor.text(message) unless redactor.nil? BulkLookupError.new( - error: BulkLookupErrorDetails.new(message: string_or_nil(item["message"])) + error: BulkLookupErrorDetails.new(message: message) ) else raise SerializationError, "Failed to deserialize API response" unless item.is_a?(Hash) @@ -83,8 +85,8 @@ def validate_json_output(output) raise ValidationError, "XML output is not supported by typed methods. Use ResponseFormat::JSON." end - def to_api_error(status_code, body) - api_message = extract_api_message(body) + def to_api_error(status_code, body, redactor = nil) + api_message = extract_api_message(body, redactor) message = if api_message.nil? "API request failed with HTTP status #{status_code}" @@ -110,22 +112,25 @@ def to_api_error(status_code, body) klass.new(message, status_code, api_message) end - def extract_api_message(body) + def extract_api_message(body, redactor = nil) return nil if body.nil? normalized = body.to_s.strip return nil if normalized.empty? + fallback = (redactor.nil? ? normalized : redactor.text(normalized))[0, 512] payload = JSON.parse(normalized) - return normalized[0, 512] unless payload.is_a?(Hash) + return fallback unless payload.is_a?(Hash) message = payload["message"] if message.nil? && payload["error"].is_a?(Hash) message = payload["error"]["message"] end - string_or_nil(message) || normalized[0, 512] + return fallback unless message.is_a?(String) + + redactor.nil? ? message : redactor.text(message) rescue JSON::ParserError - normalized[0, 512] + fallback end def parse_int_header(value) diff --git a/lib/ipgeolocation_sdk/version.rb b/lib/ipgeolocation_sdk/version.rb index 0adbb86..77d75e0 100755 --- a/lib/ipgeolocation_sdk/version.rb +++ b/lib/ipgeolocation_sdk/version.rb @@ -1,3 +1,3 @@ module IpgeolocationSdk - VERSION = "2.0.1" + VERSION = "2.0.2" end diff --git a/spec/ip_location/client_http_spec.rb b/spec/ip_location/client_http_spec.rb new file mode 100644 index 0000000..daf1b59 --- /dev/null +++ b/spec/ip_location/client_http_spec.rb @@ -0,0 +1,81 @@ +require "spec_helper" +require "socket" +require "timeout" +require "uri" + +RSpec.describe "Client HTTP requests" do + def with_server(status, body) + server = TCPServer.new("127.0.0.1", 0) + port = server.addr[1] + received = Queue.new + worker = Thread.new do + Timeout.timeout(5) do + socket = server.accept + begin + request_line = socket.gets + headers = {} + while (line = socket.gets) && line != "\r\n" + name, value = line.split(":", 2) + headers[name.downcase] = value.strip + end + payload = socket.read(headers.fetch("content-length", "0").to_i) + received << [request_line, headers, payload] + socket.write("HTTP/1.1 #{status} Test\r\nContent-Type: application/json\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n") + socket.write(body) + ensure + socket.close + end + end + end + client = IpgeolocationSdk::IpGeolocationClient.new(api_key: "test-key+/value", base_url: "http://127.0.0.1:#{port}", read_timeout: 3) + yield client + worker.value + received.pop + ensure + client&.close + server&.close + worker&.kill if worker&.alive? + worker&.join + end + + { + lookup_ip_geolocation: { ip: "8.8.8.8" }, + lookup_ip_geolocation_raw: { ip: "8.8.8.8" }, + bulk_lookup_ip_geolocation: { ips: ["8.8.8.8"] }, + bulk_lookup_ip_geolocation_raw: { ips: ["8.8.8.8"] } + }.each do |method, request| + it "sends Arabic and the original key through #{method}" do + bulk = method.to_s.start_with?("bulk") + data = { ip: "8.8.8.8", location: { country_name: "الولايات المتحدة" } } + body = JSON.generate(bulk ? [data] : data) + request_line, headers, payload = with_server(200, body) do |client| + result = client.public_send(method, request.merge(lang: IpgeolocationSdk::Language::AR)) + if method.to_s.end_with?("_raw") + expect(result.data).to eq(body) + else + location = bulk ? result.data.first.data.location : result.data.location + expect(location.country_name).to eq("الولايات المتحدة") + end + end + verb, target = request_line.split(" ") + uri = URI.parse(target) + params = URI.decode_www_form(uri.query).to_h + expect(verb).to eq(bulk ? "POST" : "GET") + expect(uri.path).to eq(bulk ? "/v3/ipgeo-bulk" : "/v3/ipgeo") + expect(params["apiKey"]).to eq("test-key+/value") + expect(params["lang"]).to eq("ar") + expect(headers["user-agent"]).to eq("ipgeolocation-ruby-sdk/2.0.2") + expect(JSON.parse(payload)).to eq({ "ips" => ["8.8.8.8"] }) if bulk + end + + it "preserves HTTP status and hides echoed keys through #{method}" do + with_server(401, "Rejected test-key+/value") do |client| + expect { client.public_send(method, request) }.to raise_error(IpgeolocationSdk::UnauthorizedError) do |error| + expect(error.status_code).to eq(401) + expect(error.api_message).to eq("Rejected [REDACTED]") + expect(error.full_message).not_to include("test-key+/value") + end + end + end + end +end diff --git a/spec/ip_location/error_redaction_spec.rb b/spec/ip_location/error_redaction_spec.rb new file mode 100644 index 0000000..5c3cffc --- /dev/null +++ b/spec/ip_location/error_redaction_spec.rb @@ -0,0 +1,198 @@ +require "spec_helper" +require "cgi" +require "json" + +RSpec.describe "Client error redaction" do + let(:key) { "test-key+/with space&value" } + let(:transport) { double("transport") } + let(:client) { IpgeolocationSdk::IpGeolocationClient.new({ api_key: key }, transport) } + + def response(status, body) + IpgeolocationSdk::HttpResponseData.new(status_code: status, body: body, headers: {}) + end + + { + lookup_ip_geolocation: {}, + lookup_ip_geolocation_raw: {}, + bulk_lookup_ip_geolocation: { ips: ["8.8.8.8"] }, + bulk_lookup_ip_geolocation_raw: { ips: ["8.8.8.8"] } + }.each do |method, request| + it "masks API errors from #{method} without changing status or type" do + { + 400 => IpgeolocationSdk::BadRequestError, + 401 => IpgeolocationSdk::UnauthorizedError, + 404 => IpgeolocationSdk::NotFoundError, + 405 => IpgeolocationSdk::MethodNotAllowedError, + 413 => IpgeolocationSdk::PayloadTooLargeError, + 415 => IpgeolocationSdk::UnsupportedMediaTypeError, + 423 => IpgeolocationSdk::LockedError, + 429 => IpgeolocationSdk::RateLimitError, + 499 => IpgeolocationSdk::ClientClosedRequestError, + 500 => IpgeolocationSdk::ServerError, + 503 => IpgeolocationSdk::ServerError, + 418 => IpgeolocationSdk::ApiError + }.each do |status, error_class| + [ + JSON.generate({ message: "Rejected #{key}" }), + JSON.generate({ error: { message: "Rejected #{CGI.escape(key)}" } }), + "Rejected #{CGI.escapeHTML(key)}" + ].each do |body| + allow(transport).to receive(:send_request).and_return(response(status, body)) + expect { client.public_send(method, request) }.to raise_error(error_class) do |error| + expect(error.status_code).to eq(status) + expect(error.message).to include("[REDACTED]") + expect(error.api_message).to include("[REDACTED]") + expect(error.full_message).not_to include(key, CGI.escape(key), CGI.escapeHTML(key)) + end + end + end + end + + it "copies transport error details from #{method} without retaining the original" do + original = IOError.new("Request #{key}") + original.instance_variable_set(:@request_url, "https://example.test/?apiKey=#{key}") + original.set_backtrace(["request #{key}:1"]) + failure = IpgeolocationSdk::RequestTimeoutError.new("Timeout #{key}", cause: original) + allow(transport).to receive(:send_request).and_raise(failure) + + expect { client.public_send(method, request) }.to raise_error(IpgeolocationSdk::RequestTimeoutError) do |error| + expect(error).not_to equal(failure) + expect(error.message).to eq("Timeout [REDACTED]") + expect(error.cause).not_to equal(original) + expect(error.cause.message).to include("[REDACTED]") + expect(error.cause.instance_variables).not_to include(:@request_url) + expect(error.cause.backtrace.join).not_to include(key) + expect(error.full_message).not_to include(key) + expect(Exception.instance_method(:cause).bind(error).call).to be_nil + end + expect(original.message).to include(key) + end + end + + it "masks bulk error items while preserving successful data" do + body = JSON.generate([{ ip: "8.8.8.8", hostname: key }, { success: false, message: "Rejected #{key}" }]) + allow(transport).to receive(:send_request).and_return(response(200, body)) + results = client.bulk_lookup_ip_geolocation(ips: ["8.8.8.8", "bad"]).data + + expect(results.first.data.hostname).to eq(key) + expect(results.last.error.message).to eq("Rejected [REDACTED]") + expect(results.last.success?).to eq(false) + end + + it "preserves successful raw JSON and XML exactly" do + ["{\"message\":\"#{key}\"}", "#{key}"].each do |body| + allow(transport).to receive(:send_request).and_return(response(200, body)) + expect(client.lookup_ip_geolocation_raw.data).to eq(body) + expect(client.bulk_lookup_ip_geolocation_raw(ips: ["8.8.8.8"]).data).to eq(body) + end + end + + it "redacts before shortening fallback error bodies" do + ["x" * 500 + key, JSON.generate(["x" * 500 + key])].each do |body| + allow(transport).to receive(:send_request).and_return(response(401, body)) + expect { client.lookup_ip_geolocation }.to raise_error(IpgeolocationSdk::UnauthorizedError) do |error| + expect(error.api_message).not_to include(key[0, 10]) + expect(error.api_message.length).to be <= 512 + end + end + end + + it "preserves HTTP errors when the message is missing or not a string" do + [nil, "", "null", "[]", "true", "not JSON", JSON.generate({ message: true }), JSON.generate({ error: { message: [] } })].each do |body| + allow(transport).to receive(:send_request).and_return(response(401, body)) + expect { client.lookup_ip_geolocation }.to raise_error(IpgeolocationSdk::UnauthorizedError) do |error| + expect(error.status_code).to eq(401) + end + end + end + + it "handles repeated and escaped keys in errors" do + special_key = "test-\"key\\value+/ space" + special_client = IpgeolocationSdk::IpGeolocationClient.new({ api_key: special_key }, transport) + escaped = CGI.escape(special_key).gsub(/%[0-9A-F]{2}/, &:downcase).gsub("+", "%20") + body = JSON.generate({ message: "#{special_key} #{escaped} #{JSON.generate(special_key)[1...-1]}" }) + allow(transport).to receive(:send_request).and_return(response(401, body)) + expect { special_client.lookup_ip_geolocation }.to raise_error(IpgeolocationSdk::UnauthorizedError) do |error| + expect(error.api_message).to eq("[REDACTED] [REDACTED] [REDACTED]") + end + end + + it "limits cyclic error causes without retaining the original chain" do + failure = IpgeolocationSdk::TransportError.new("Request #{key}") + failure.instance_variable_set(:@cause, failure) + allow(transport).to receive(:send_request).and_raise(failure) + expect { client.lookup_ip_geolocation }.to raise_error(IpgeolocationSdk::TransportError) do |error| + seen = [] + while error + expect(error).not_to equal(failure) + expect(error.message).not_to include(key) + seen << error + expect(seen.length).to be <= 10 + error = error.cause + end + end + end + + it "masks invalid JSON in parser error details" do + allow(transport).to receive(:send_request).and_return(response(200, "{invalid: #{key}")) + expect { client.lookup_ip_geolocation }.to raise_error(IpgeolocationSdk::SerializationError) do |error| + expect(error.cause).not_to be_nil + expect(error.cause.message).not_to include(key) + expect(error.full_message).not_to include(key) + end + end + + it "keeps errors unchanged when no API key is configured" do + failure = IpgeolocationSdk::TransportError.new("connection failed", cause: IOError.new("socket")) + allow(transport).to receive(:send_request).and_raise(failure) + origin_client = IpgeolocationSdk::IpGeolocationClient.new({ request_origin: "https://example.test" }, transport) + expect { origin_client.lookup_ip_geolocation }.to raise_error { |error| expect(error).to equal(failure) } + end + + it "uses the current API key when the configuration string changes" do + client.config.api_key.replace("rotated-test-key") + allow(transport).to receive(:send_request) do |request| + expect(request.url).to include("apiKey=rotated-test-key") + response(401, JSON.generate({ message: "Rejected rotated-test-key" })) + end + expect { client.lookup_ip_geolocation }.to raise_error(IpgeolocationSdk::UnauthorizedError) do |error| + expect(error.api_message).to eq("Rejected [REDACTED]") + end + end + + it "masks the request key even if the configuration changes during the request" do + allow(transport).to receive(:send_request) do |request| + expect(request.url).to include(CGI.escape(key)) + body = JSON.generate({ message: "Rejected #{key}" }) + client.config.api_key.replace("rotated-test-key") + response(401, body) + end + expect { client.lookup_ip_geolocation }.to raise_error(IpgeolocationSdk::UnauthorizedError) do |error| + expect(error.api_message).to eq("Rejected [REDACTED]") + end + end + + it "keeps a safe transport error when a custom constructor needs extra arguments" do + custom_error = Class.new(IpgeolocationSdk::RequestTimeoutError) do + def initialize(message, request:) + super(message) + @request = request + end + end + failure = custom_error.new("Timeout #{key}", request: key) + allow(transport).to receive(:send_request).and_raise(failure) + expect { client.lookup_ip_geolocation }.to raise_error(IpgeolocationSdk::RequestTimeoutError) do |error| + expect(error.message).to eq("Timeout [REDACTED]") + expect(error.instance_variables).not_to include(:@request) + expect(error.full_message).not_to include(key) + expect(Exception.instance_method(:cause).bind(error).call).to be_nil + end + end + + it "retains validation error types and masks values echoed in validation messages" do + expect { client.lookup_ip_geolocation(lang: key) }.to raise_error(IpgeolocationSdk::ValidationError) do |error| + expect(error.message).to eq("Unsupported language code: [REDACTED]") + end + expect { client.lookup_ip_geolocation(ip: 42) }.to raise_error(TypeError, "ip must be a string") + end +end diff --git a/spec/ip_location/public_api_spec.rb b/spec/ip_location/public_api_spec.rb index 3742fc8..2d101e1 100644 --- a/spec/ip_location/public_api_spec.rb +++ b/spec/ip_location/public_api_spec.rb @@ -2,7 +2,7 @@ RSpec.describe "IpgeolocationSdk public API" do it "exposes the current gem version" do - expect(IpgeolocationSdk::VERSION).to eq("2.0.1") + expect(IpgeolocationSdk::VERSION).to eq("2.0.2") end it "serializes value objects through the module helpers" do diff --git a/spec/ip_location/request_models_spec.rb b/spec/ip_location/request_models_spec.rb index 017470d..12e0cac 100644 --- a/spec/ip_location/request_models_spec.rb +++ b/spec/ip_location/request_models_spec.rb @@ -54,3 +54,17 @@ end.to raise_error(IpgeolocationSdk::ValidationError, "header values must not contain CR or LF") end end + +RSpec.describe IpgeolocationSdk::Language do + it "accepts Arabic alongside the existing language codes" do + expect(described_class::AR).to eq("ar") + (described_class::VALUES + [" AR ", :AR]).each do |language| + expect(described_class.valid?(language)).to eq(true) + expected = language.to_s.strip.downcase + expect(IpgeolocationSdk::LookupIpGeolocationRequest.new(lang: language).lang).to eq(expected) + expect(IpgeolocationSdk::BulkLookupIpGeolocationRequest.new(ips: ["8.8.8.8"], lang: language).lang).to eq(expected) + end + expect(described_class.valid?("unknown")).to eq(false) + expect { described_class.from_code("unknown") }.to raise_error(IpgeolocationSdk::ValidationError) + end +end From a2cee047084c8ea42716b496e454e78685c3d0c2 Mon Sep 17 00:00:00 2001 From: MudassarTariq Date: Wed, 4 Nov 2026 15:05:39 +0500 Subject: [PATCH 2/2] Normalize request keys before masking errors --- lib/ipgeolocation_sdk/client.rb | 8 ++++---- spec/ip_location/error_redaction_spec.rb | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/ipgeolocation_sdk/client.rb b/lib/ipgeolocation_sdk/client.rb index 39208dc..69657a9 100644 --- a/lib/ipgeolocation_sdk/client.rb +++ b/lib/ipgeolocation_sdk/client.rb @@ -33,7 +33,7 @@ def inspect end def lookup_ip_geolocation(request = nil) - api_key = @config.api_key&.dup + api_key = @config.api_key&.strip redactor = ErrorRedactor.new(api_key) ensure_open typed_request = to_lookup_request(request) @@ -54,7 +54,7 @@ def lookup_ip_geolocation(request = nil) end def lookup_ip_geolocation_raw(request = nil) - api_key = @config.api_key&.dup + api_key = @config.api_key&.strip redactor = ErrorRedactor.new(api_key) ensure_open typed_request = to_lookup_request(request) @@ -74,7 +74,7 @@ def lookup_ip_geolocation_raw(request = nil) end def bulk_lookup_ip_geolocation(request) - api_key = @config.api_key&.dup + api_key = @config.api_key&.strip redactor = ErrorRedactor.new(api_key) ensure_open typed_request = to_bulk_request(request) @@ -95,7 +95,7 @@ def bulk_lookup_ip_geolocation(request) end def bulk_lookup_ip_geolocation_raw(request) - api_key = @config.api_key&.dup + api_key = @config.api_key&.strip redactor = ErrorRedactor.new(api_key) ensure_open typed_request = to_bulk_request(request) diff --git a/spec/ip_location/error_redaction_spec.rb b/spec/ip_location/error_redaction_spec.rb index 5c3cffc..0a26dfd 100644 --- a/spec/ip_location/error_redaction_spec.rb +++ b/spec/ip_location/error_redaction_spec.rb @@ -150,7 +150,7 @@ def response(status, body) end it "uses the current API key when the configuration string changes" do - client.config.api_key.replace("rotated-test-key") + client.config.api_key.replace(" rotated-test-key ") allow(transport).to receive(:send_request) do |request| expect(request.url).to include("apiKey=rotated-test-key") response(401, JSON.generate({ message: "Rejected rotated-test-key" }))