diff --git a/Steepfile b/Steepfile index d81fac414..c8fc46dc0 100644 --- a/Steepfile +++ b/Steepfile @@ -18,10 +18,12 @@ target :lib do # Use the stubs located at sig/custom/.rbs instead. library 'date' library 'fileutils' + library 'ipaddr' library 'logger' library 'json' library 'openssl' library 'pathname' + library 'resolv' library 'tempfile' library 'time' library 'uri' diff --git a/lib/mindee/http.rb b/lib/mindee/http.rb index 5e45e2e81..67a94ad54 100644 --- a/lib/mindee/http.rb +++ b/lib/mindee/http.rb @@ -1,3 +1,4 @@ # frozen_string_literal: true +require_relative 'http/cancellation_token' require_relative 'http/http_error_handler' diff --git a/lib/mindee/http/cancellation_token.rb b/lib/mindee/http/cancellation_token.rb new file mode 100644 index 000000000..563d4d933 --- /dev/null +++ b/lib/mindee/http/cancellation_token.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Mindee + module HTTP + # Custom cancellation token class for polling. + class CancellationToken + private attr_reader :is_canceled + + def initialize + @is_canceled = false + end + + # Cancel the token. + def cancel + @is_canceled = true + end + + # Check if the token is canceled. + def canceled? + @is_canceled + end + end + end +end diff --git a/lib/mindee/input/sources/url_input_source.rb b/lib/mindee/input/sources/url_input_source.rb index 3947c7233..dacc3d15e 100644 --- a/lib/mindee/input/sources/url_input_source.rb +++ b/lib/mindee/input/sources/url_input_source.rb @@ -3,6 +3,8 @@ require 'net/http' require 'uri' require 'fileutils' +require 'ipaddr' +require 'resolv' require_relative '../../logging' module Mindee @@ -10,14 +12,27 @@ module Input module Source # Load a remote document from a file url. class URLInputSource + # IP ranges that must not be the target of a source URL (SSRF protection). + DISALLOWED_RANGES = [ + IPAddr.new('127.0.0.0/8'), # IPv4 loopback + IPAddr.new('::1/128'), # IPv6 loopback + IPAddr.new('169.254.0.0/16'), # IPv4 link-local + IPAddr.new('fe80::/10'), # IPv6 link-local + IPAddr.new('10.0.0.0/8'), # RFC 1918 + IPAddr.new('172.16.0.0/12'), # RFC 1918 + IPAddr.new('192.168.0.0/16'), # RFC 1918 + IPAddr.new('0.0.0.0/8'), # "this" network + IPAddr.new('224.0.0.0/4'), # IPv4 multicast + IPAddr.new('ff00::/8'), # IPv6 multicast + IPAddr.new('fc00::/7'), # IPv6 unique-local + IPAddr.new('100.64.0.0/10'), # Carrier-grade NAT (RFC 6598) + ].freeze # @return [String] attr_reader :url def initialize(url) - raise Error::MindeeInputError, 'URL must be HTTPS' unless url.start_with? 'https://' - + validate_url!(url) logger.debug("URL input: #{url}") - @url = url end @@ -85,6 +100,62 @@ def fetch_file_content(username: nil, password: nil, token: nil, max_redirects: private + # Validates the URL against SSRF risks before it is stored or fetched. + # Checks scheme, embedded credentials, loopback hostnames, and resolved addresses. + # @param url [String] + # @raise [Error::MindeeInputError] + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def validate_url!(url) + uri = URI.parse(url) + + raise Error::MindeeInputError, 'Only HTTPS source URLs are allowed' unless uri.scheme&.downcase == 'https' + + userinfo = uri.userinfo + raise Error::MindeeInputError, 'Source URLs must not embed user credentials' if userinfo && !userinfo.empty? + + host = uri.hostname + raise Error::MindeeInputError, 'Source URL is missing a host' if host.nil? || host.empty? + + lower_host = host.downcase + if lower_host == 'localhost' || lower_host.end_with?('.localhost') || + lower_host == 'ip6-localhost' || lower_host == 'ip6-loopback' + raise Error::MindeeInputError, "Loopback hostnames are not allowed: #{host}" + end + + resolved_addresses(host).each do |addr| + if DISALLOWED_RANGES.any? { |range| range.include?(addr) } + raise Error::MindeeInputError, + "Source URL host resolves to a disallowed address: #{addr}" + end + end + rescue URI::InvalidURIError + raise Error::MindeeInputError, "Invalid URL: #{url}" + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + # Resolves a host to an array of IPAddr objects. + # Handles literal IP addresses directly without a DNS round-trip. + # @param host [String] + # @return [Array] + # @raise [Error::MindeeInputError] if the host cannot be resolved. + def resolved_addresses(host) + return [IPAddr.new(host)] if literal_ip?(host) + + addrs = Resolv.getaddresses(host) + raise Error::MindeeInputError, "Unable to resolve source URL host: #{host}" if addrs.empty? + + addrs.map { |a| IPAddr.new(a) } + end + + # Returns true if the string is a valid literal IPv4 or IPv6 address. + # @param host [String] + def literal_ip?(host) + IPAddr.new(host) + true + rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError + false + end + def extract_filename_from_url(uri) filename = File.basename(uri.path.to_s) filename.empty? ? '' : filename @@ -105,6 +176,7 @@ def make_request(uri, request, max_redirects) location = response['location'] raise Error::MindeeInputError, 'No location in redirection header.' if location.nil? + validate_url!(location) new_uri = URI.parse(location) request = Net::HTTP::Get.new(new_uri) make_request(new_uri, request, max_redirects - 1) diff --git a/lib/mindee/v1/client.rb b/lib/mindee/v1/client.rb index 96319a263..e0e4589ee 100644 --- a/lib/mindee/v1/client.rb +++ b/lib/mindee/v1/client.rb @@ -219,6 +219,7 @@ def parse_queued(job_id, product_class, endpoint: nil) Mindee::V1::Parsing::Common::ApiResponse.new(product_class, prediction, raw_http) end + # rubocop:disable Metrics/CyclomaticComplexity # Enqueue a document for async parsing and automatically try to retrieve it # # @param input_source [Mindee::Input::Source::LocalInputSource, Mindee::Input::Source::URLInputSource] @@ -247,13 +248,23 @@ def parse_queued(job_id, product_class, endpoint: nil) # * `:delay_sec` [Numeric] Delay between polling attempts. Defaults to 1.5. # * `:max_retries` [Integer] Maximum number of retries. Defaults to 80. # @param endpoint [Mindee::V1::HTTP::Endpoint] Endpoint of the API. + # @param cancellation_token [Mindee::HTTP::CancellationToken, nil] Token for cancellation. # @return [Mindee::V1::Parsing::Common::ApiResponse] - def enqueue_and_parse(input_source, product_class, endpoint, options) + # rubocop:disable Metrics/PerceivedComplexity + def enqueue_and_parse( + input_source, + product_class, + endpoint, + options, + cancellation_token = nil + ) validate_async_params(options.initial_delay_sec, options.delay_sec, options.max_retries) enqueue_res = enqueue(input_source, product_class, endpoint: endpoint, options: options) job = enqueue_res.job or raise Error::MindeeAPIError, 'Expected job to be present' job_id = job.id + raise Mindee::Error::MindeeError, 'Enqueueing of the document was canceled.' if cancellation_token&.canceled? + sleep(options.initial_delay_sec) polling_attempts = 1 logger.debug("Successfully enqueued document with job id: '#{job_id}'") @@ -266,6 +277,8 @@ def enqueue_and_parse(input_source, product_class, endpoint, options) # @type var valid_statuses: Array[(:waiting | :processing | :completed | :failed)] while valid_statuses.include?(queue_res_job.status) && polling_attempts < options.max_retries logger.debug("Polling server for parsing result with job id: '#{job_id}'. Attempt #{polling_attempts}") + raise Mindee::Error::MindeeError, 'Enqueueing of the document was canceled.' if cancellation_token&.canceled? + sleep(options.delay_sec) queue_res = parse_queued(job_id, product_class, endpoint: endpoint) queue_res_job = queue_res.job or raise Error::MindeeAPIError, 'Expected job to be present' @@ -280,7 +293,9 @@ def enqueue_and_parse(input_source, product_class, endpoint, options) queue_res end + # rubocop:enable Metrics/PerceivedComplexity + # rubocop:enable Metrics/CyclomaticComplexity # Sends a document to a workflow. # # Accepts options either as a Hash or as a WorkflowOptions struct. diff --git a/lib/mindee/v2/client.rb b/lib/mindee/v2/client.rb index 7165e6ce8..bc4fad7ab 100644 --- a/lib/mindee/v2/client.rb +++ b/lib/mindee/v2/client.rb @@ -58,12 +58,15 @@ def enqueue( # The source of the input document (local file or URL). # @param params [Hash, Input::BaseParameters] Parameters for the inference. # @param polling_options [Hash, PollingOptions, nil] Parameters for polling. + # @param cancellation_token [Mindee::HTTP::CancellationToken, nil] Token for cancellation. # @return [Parsing::BaseResponse] + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def enqueue_and_get_result( product, input_source, params, - polling_options = nil + polling_options = nil, + cancellation_token = nil ) enqueue_response = enqueue(product, input_source, params) normalized_params = normalize_parameters(product.params_type, params, polling_options: polling_options) @@ -77,6 +80,8 @@ def enqueue_and_get_result( job_id = enqueue_response.job.id logger.debug("Successfully enqueued document with job id: #{job_id}.") + raise Mindee::Error::MindeeError, 'Enqueueing of the document was canceled.' if cancellation_token&.canceled? + sleep(normalized_params.polling_options.initial_delay_sec) retry_counter = 1 poll_results = get_job(job_id) @@ -94,6 +99,8 @@ def enqueue_and_get_result( "Job status: #{poll_results.job.status}." ) + raise Mindee::Error::MindeeError, 'Enqueueing of the document was canceled.' if cancellation_token&.canceled? + sleep(normalized_params.polling_options.delay_sec) poll_results = get_job(job_id) retry_counter += 1 @@ -110,6 +117,7 @@ def enqueue_and_get_result( raise Mindee::Error::MindeeError, "Asynchronous parsing request timed out after #{sec_count} seconds" end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity # Searches for a list of available models for the given API key. # @param model_name [String] diff --git a/lib/mindee/v2/parsing/field/field_confidence.rb b/lib/mindee/v2/parsing/field/field_confidence.rb index 647196742..c19bf313c 100644 --- a/lib/mindee/v2/parsing/field/field_confidence.rb +++ b/lib/mindee/v2/parsing/field/field_confidence.rb @@ -87,8 +87,8 @@ def >=(other) end end - # less than or equality of two FieldConfidence instances. - # # @param other [String, Integer, FieldConfidence] The other confidence to compare. + # Less than or equality of two FieldConfidence instances. + # @param other [String, Integer, FieldConfidence] The other confidence to compare. def <=(other) if other.is_a?(FieldConfidence) to_i <= val_to_i(other.value) @@ -101,12 +101,47 @@ def <=(other) end end + # Greater than comparison of two FieldConfidence instances. + # @param other [String, Integer, FieldConfidence] The other confidence to compare. + def >(other) + if other.is_a?(FieldConfidence) + to_i > val_to_i(other.value) + elsif other.is_a?(String) + to_i > val_to_i(other) + elsif other.is_a?(Integer) + to_i > other + else + raise ArgumentError, "Invalid type: #{other.class}" + end + end + + # Less than comparison of two FieldConfidence instances. + # @param other [String, Integer, FieldConfidence] The other confidence to compare. + def <(other) + if other.is_a?(FieldConfidence) + to_i < val_to_i(other.value) + elsif other.is_a?(String) + to_i < val_to_i(other) + elsif other.is_a?(Integer) + to_i < other + else + raise ArgumentError, "Invalid type: #{other.class}" + end + end + # rubocop:enable Style/CaseLikeIf # Aliases for the comparison operators. alias eql? == + alias equal_value? == alias gteql? >= + alias greater_than_or_equal? >= alias lteql? <= + alias less_than_or_equal? <= + alias gt? > + alias greater_than? > + alias lt? < + alias less_than? < protected diff --git a/sig/mindee/http/cancellation_token.rbs b/sig/mindee/http/cancellation_token.rbs new file mode 100644 index 000000000..e0efe7009 --- /dev/null +++ b/sig/mindee/http/cancellation_token.rbs @@ -0,0 +1,15 @@ +# lib/http/cancellation_token.rb + +module Mindee + module HTTP + class CancellationToken + @is_canceled: bool + + attr_reader is_canceled: bool + + def canceled?: -> bool + def initialize: -> void + def cancel: -> void + end + end +end diff --git a/sig/mindee/input/sources/url_input_source.rbs b/sig/mindee/input/sources/url_input_source.rbs index 77d2e0361..174e69cef 100644 --- a/sig/mindee/input/sources/url_input_source.rbs +++ b/sig/mindee/input/sources/url_input_source.rbs @@ -3,6 +3,8 @@ module Mindee module Input module Source class URLInputSource + DISALLOWED_RANGES: Array[IPAddr] + def url: -> String def logger: () -> Logger def initialize: (String) -> void @@ -14,6 +16,9 @@ module Mindee def make_request: (URI::Generic, Net::HTTP::Get, Integer) -> Net::HTTPRedirection def get_file_extension: (String) -> String? def generate_file_name: (?extension: String?) -> String + def validate_url!: (String) -> void + def resolved_addresses: (String) -> Array[IPAddr] + def literal_ip?: (String) -> bool end end end diff --git a/sig/mindee/v2/parsing/field/field_confidence.rbs b/sig/mindee/v2/parsing/field/field_confidence.rbs index e1c72817a..6eb288dc8 100644 --- a/sig/mindee/v2/parsing/field/field_confidence.rbs +++ b/sig/mindee/v2/parsing/field/field_confidence.rbs @@ -20,6 +20,15 @@ module Mindee def lteql? : (String | Integer | FieldConfidence) -> bool def >=: (String | Integer | FieldConfidence) -> bool def gteql?: (String | Integer | FieldConfidence) -> bool + def greater_than_or_equal?: (String | Integer | FieldConfidence) -> bool + def >: (String | Integer | FieldConfidence) -> bool + def gt?: (String | Integer | FieldConfidence) -> bool + def greater_than?: (String | Integer | FieldConfidence) -> bool + def <: (String | Integer | FieldConfidence) -> bool + def lt?: (String | Integer | FieldConfidence) -> bool + def less_than?: (String | Integer | FieldConfidence) -> bool + def less_than_or_equal?: (String | Integer | FieldConfidence) -> bool + def equal_value?: (String | Integer | FieldConfidence) -> bool def inspect: -> String def val_to_i: (String) -> Integer diff --git a/spec/http/cancellation_token_spec.rb b/spec/http/cancellation_token_spec.rb new file mode 100644 index 000000000..0a161c838 --- /dev/null +++ b/spec/http/cancellation_token_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'mindee' + +describe Mindee::HTTP::CancellationToken do + it 'starts in a not-cancelled state' do + token = described_class.new + expect(token.canceled?).to be false + end + + it 'is cancelled after calling #cancel' do + token = described_class.new + token.cancel + expect(token.canceled?).to be true + end + + it 'remains cancelled once cancel has been called' do + token = described_class.new + token.cancel + token.cancel + expect(token.canceled?).to be true + end +end diff --git a/spec/input/sources/url_input_source_spec.rb b/spec/input/sources/url_input_source_spec.rb index 87407af02..920d1d296 100644 --- a/spec/input/sources/url_input_source_spec.rb +++ b/spec/input/sources/url_input_source_spec.rb @@ -9,6 +9,12 @@ let(:invalid_url) { 'http://invalidurl/some/file.jpg' } let(:output_dir) { "#{ROOT_DATA_DIR}/output/" } + # Stub DNS for hosts used in the existing tests so they pass validation. + before do + allow(Resolv).to receive(:getaddresses).with('validurl').and_return(['1.2.3.4']) + allow(Resolv).to receive(:getaddresses).with('platform.mindee.com').and_return(['1.2.3.4']) + end + describe '#initialize' do context 'with valid URL' do it 'creates a new instance' do @@ -65,6 +71,143 @@ end end + describe '#validate_url!' do + let(:safe_url) { 'https://safe.example.com/file.pdf' } + + before do + allow(Resolv).to receive(:getaddresses).with('safe.example.com').and_return(['1.2.3.4']) + end + + context 'scheme' do + it 'accepts https' do + expect { described_class.new(safe_url) }.not_to raise_error + end + + it 'rejects http' do + expect { described_class.new('http://safe.example.com/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{Only HTTPS}) + end + + it 'rejects ftp' do + expect { described_class.new('ftp://safe.example.com/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{Only HTTPS}) + end + end + + context 'embedded credentials' do + it 'rejects URLs with user:password' do + allow(Resolv).to receive(:getaddresses).with('safe.example.com').and_return(['1.2.3.4']) + expect { described_class.new('https://user:pass@safe.example.com/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{credentials}) + end + + it 'rejects URLs with username only' do + allow(Resolv).to receive(:getaddresses).with('safe.example.com').and_return(['1.2.3.4']) + expect { described_class.new('https://user@safe.example.com/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{credentials}) + end + end + + context 'loopback hostnames' do + it 'rejects localhost' do + expect { described_class.new('https://localhost/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{Loopback}) + end + + it 'rejects subdomains of localhost' do + expect { described_class.new('https://evil.localhost/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{Loopback}) + end + + it 'rejects ip6-localhost' do + expect { described_class.new('https://ip6-localhost/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{Loopback}) + end + + it 'rejects ip6-loopback' do + expect { described_class.new('https://ip6-loopback/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{Loopback}) + end + end + + context 'literal IP addresses' do + it 'rejects IPv4 loopback (127.0.0.1)' do + expect { described_class.new('https://127.0.0.1/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{disallowed address}) + end + + it 'rejects any 127.x.x.x address' do + expect { described_class.new('https://127.0.0.2/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{disallowed address}) + end + + it 'rejects IPv6 loopback (::1)' do + expect { described_class.new('https://[::1]/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{disallowed address}) + end + + it 'rejects RFC 1918 — 10.x.x.x' do + expect { described_class.new('https://10.0.0.1/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{disallowed address}) + end + + it 'rejects RFC 1918 — 172.16.x.x' do + expect { described_class.new('https://172.16.0.1/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{disallowed address}) + end + + it 'rejects RFC 1918 — 192.168.x.x' do + expect { described_class.new('https://192.168.1.1/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{disallowed address}) + end + + it 'rejects link-local — 169.254.x.x' do + expect { described_class.new('https://169.254.0.1/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{disallowed address}) + end + + it 'rejects carrier-grade NAT — 100.64.x.x' do + expect { described_class.new('https://100.64.0.1/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{disallowed address}) + end + + it 'rejects any-local — 0.0.0.0' do + expect { described_class.new('https://0.0.0.0/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{disallowed address}) + end + + it 'rejects IPv6 unique-local (fc00::1)' do + expect { described_class.new('https://[fc00::1]/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{disallowed address}) + end + + it 'rejects IPv6 link-local (fe80::1)' do + expect { described_class.new('https://[fe80::1]/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{disallowed address}) + end + end + + context 'hostname resolving to a disallowed address' do + it 'rejects a hostname that resolves to a private IP' do + allow(Resolv).to receive(:getaddresses).with('internal.corp').and_return(['192.168.1.50']) + expect { described_class.new('https://internal.corp/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{disallowed address}) + end + + it 'rejects a hostname that resolves to a loopback IP' do + allow(Resolv).to receive(:getaddresses).with('fake-local.example.com').and_return(['127.0.0.1']) + expect { described_class.new('https://fake-local.example.com/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{disallowed address}) + end + + it 'rejects an unresolvable hostname' do + allow(Resolv).to receive(:getaddresses).with('unresolvable.invalid').and_return([]) + expect { described_class.new('https://unresolvable.invalid/file.pdf') } + .to raise_error(Mindee::Error::MindeeInputError, %r{Unable to resolve}) + end + end + end + describe '#write_to_file' do let(:url_input_source) { described_class.new(valid_url) } let(:url_input_source_no_filename) { described_class.new(valid_url_no_filename) } diff --git a/spec/v1/client_spec.rb b/spec/v1/client_spec.rb index 8d11519c0..2b2cab05c 100644 --- a/spec/v1/client_spec.rb +++ b/spec/v1/client_spec.rb @@ -121,4 +121,58 @@ end.to raise_error Mindee::Error::MindeeConfigurationError end end + + context 'Cancellation token' do + let(:client) { Mindee::V1::Client.new(api_key: 'dummy-key') } + let(:input_source) { Mindee::Input::Source::PathInputSource.new(File.join(FILE_TYPES_DIR, 'receipt.jpg')) } + let(:product_class) { Mindee::V1::Product::InvoiceSplitter::InvoiceSplitterV1 } + let(:endpoint) do + Mindee::V1::HTTP::Endpoint.new('mindee', 'invoice_splitter_async', '1', api_key: 'dummy-key') + end + let(:opts) { Mindee::V1::ParseOptions.new(params: { initial_delay_sec: 2, delay_sec: 1.5, max_retries: 2 }) } + + let(:mock_enqueue_res) do + data = load_json(V1_ASYNC_DIR, 'post_success.json') + Mindee::V1::Parsing::Common::ApiResponse.new(product_class, data, JSON.generate(data)) + end + let(:mock_processing_res) do + data = load_json(V1_ASYNC_DIR, 'get_processing.json') + Mindee::V1::Parsing::Common::ApiResponse.new(product_class, data, JSON.generate(data)) + end + + before do + allow(client).to receive(:sleep) + allow(client).to receive(:enqueue).and_return(mock_enqueue_res) + allow(client).to receive(:parse_queued).and_return(mock_processing_res) + end + + it 'raises MindeeError when token is cancelled before first poll' do + token = Mindee::HTTP::CancellationToken.new + token.cancel + + expect do + client.send(:enqueue_and_parse, input_source, product_class, endpoint, opts, token) + end.to raise_error(Mindee::Error::MindeeError, %r{canceled}) + end + + it 'raises MindeeError when token is cancelled during poll loop' do + token = Mindee::HTTP::CancellationToken.new + call_count = 0 + allow(client).to receive(:parse_queued) do + call_count += 1 + token.cancel if call_count == 1 + mock_processing_res + end + + expect do + client.send(:enqueue_and_parse, input_source, product_class, endpoint, opts, token) + end.to raise_error(Mindee::Error::MindeeError, %r{canceled}) + end + + it 'raises timeout error (not cancel error) when no token is passed' do + expect do + client.send(:enqueue_and_parse, input_source, product_class, endpoint, opts) + end.to raise_error(Mindee::Error::MindeeAPIError, %r{timed out}) + end + end end diff --git a/spec/v2/client_v2_spec.rb b/spec/v2/client_v2_spec.rb index 67236dcbd..d64185929 100644 --- a/spec/v2/client_v2_spec.rb +++ b/spec/v2/client_v2_spec.rb @@ -119,5 +119,63 @@ def stub_next_request_with(method, hash:, status_code: 0) resp.job.completed_at.strftime('%Y-%m-%dT%H:%M:%S.%6N') ).to eq('2026-04-20T18:32:02.734312') end + context 'Cancellation token' do + let(:processing_json) { File.read(File.join(V2_DATA_DIR, 'job', 'ok_processing.json')) } + + let(:mock_enqueue_res) do + Mindee::V2::Parsing::JobResponse.new(JSON.parse(processing_json, object_class: Hash)) + end + let(:mock_processing_res) do + Mindee::V2::Parsing::JobResponse.new(JSON.parse(processing_json, object_class: Hash)) + end + let(:params) do + Mindee::V2::Product::Extraction::Params::ExtractionParameters.new( + 'dummy-model', + polling_options: Mindee::Input::PollingOptions.new(initial_delay_sec: 2, delay_sec: 1.5, max_retries: 2) + ) + end + + before do + allow(client).to receive(:sleep) + allow(client).to receive(:enqueue).and_return(mock_enqueue_res) + allow(client).to receive(:get_job).and_return(mock_processing_res) + end + + it 'raises MindeeError when token is cancelled before first poll' do + token = Mindee::HTTP::CancellationToken.new + token.cancel + + expect do + client.enqueue_and_get_result( + Mindee::V2::Product::Extraction::Extraction, input_doc, params, nil, token + ) + end.to raise_error(Mindee::Error::MindeeError, %r{canceled}) + end + + it 'raises MindeeError when token is cancelled during poll loop' do + token = Mindee::HTTP::CancellationToken.new + call_count = 0 + allow(client).to receive(:get_job) do + call_count += 1 + token.cancel if call_count == 1 + mock_processing_res + end + + expect do + client.enqueue_and_get_result( + Mindee::V2::Product::Extraction::Extraction, input_doc, params, nil, token + ) + end.to raise_error(Mindee::Error::MindeeError, %r{canceled}) + end + + it 'raises timeout error (not cancel error) when no token is passed' do + expect do + client.enqueue_and_get_result( + Mindee::V2::Product::Extraction::Extraction, input_doc, params + ) + end.to raise_error(Mindee::Error::MindeeError, %r{timed out}) + end + end + ENV.delete('MINDEE_V2_BASE_URL') end diff --git a/spec/v2/parsing/field_confidence_spec.rb b/spec/v2/parsing/field_confidence_spec.rb new file mode 100644 index 000000000..4c3421ca5 --- /dev/null +++ b/spec/v2/parsing/field_confidence_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'mindee' +require 'mindee/v2/parsing/field' + +describe Mindee::V2::Parsing::Field::FieldConfidence do + let(:certain) { described_class.new('Certain') } + let(:high) { described_class.new('High') } + let(:medium) { described_class.new('Medium') } + let(:low) { described_class.new('Low') } + + describe '#>' do + it 'returns true when this confidence is greater than a FieldConfidence' do + expect(high > medium).to be true + expect(certain > low).to be true + end + + it 'returns false when this confidence is less than a FieldConfidence' do + expect(medium > high).to be false + end + + it 'returns false when both confidences are equal' do + expect(high > described_class.new('High')).to be false + end + + it 'compares against a String' do + expect(high > 'Medium').to be true + expect(low > 'High').to be false + end + + it 'compares against an Integer' do + expect(high > 2).to be true + expect(low > 3).to be false + end + + it 'raises ArgumentError for invalid types' do + expect { high > :invalid }.to raise_error(ArgumentError) + end + end + + describe '#<' do + it 'returns true when this confidence is less than a FieldConfidence' do + expect(medium < high).to be true + expect(low < certain).to be true + end + + it 'returns false when this confidence is greater than a FieldConfidence' do + expect(high < medium).to be false + end + + it 'returns false when both confidences are equal' do + expect(medium < described_class.new('Medium')).to be false + end + + it 'compares against a String' do + expect(medium < 'High').to be true + expect(high < 'Low').to be false + end + + it 'compares against an Integer' do + expect(low < 3).to be true + expect(certain < 1).to be false + end + + it 'raises ArgumentError for invalid types' do + expect { low < :invalid }.to raise_error(ArgumentError) + end + end + + describe 'named aliases' do + describe '#greater_than? / #gt?' do + it 'is an alias for >' do + expect(high.greater_than?(medium)).to be true + expect(medium.greater_than?(high)).to be false + expect(high.greater_than?(described_class.new('High'))).to be false + expect(high.gt?(medium)).to be true + expect(medium.gt?(high)).to be false + end + end + + describe '#less_than? / #lt?' do + it 'is an alias for <' do + expect(medium.less_than?(high)).to be true + expect(high.less_than?(medium)).to be false + expect(medium.less_than?(described_class.new('Medium'))).to be false + expect(medium.lt?(high)).to be true + expect(high.lt?(medium)).to be false + end + end + + describe '#greater_than_or_equal? / #gteql?' do + it 'is an alias for >=' do + expect(high.greater_than_or_equal?(medium)).to be true + expect(high.greater_than_or_equal?(described_class.new('High'))).to be true + expect(low.greater_than_or_equal?(medium)).to be false + expect(high.gteql?(medium)).to be true + end + end + + describe '#less_than_or_equal? / #lteql?' do + it 'is an alias for <=' do + expect(medium.less_than_or_equal?(high)).to be true + expect(medium.less_than_or_equal?(described_class.new('Medium'))).to be true + expect(high.less_than_or_equal?(low)).to be false + expect(medium.lteql?(high)).to be true + end + end + + describe '#equal_value? / #eql?' do + it 'returns true for equal confidence values' do + expect(high.equal_value?(described_class.new('High'))).to be true + expect(high.eql?(described_class.new('High'))).to be true + end + + it 'returns false for different confidence values' do + expect(high.equal_value?(medium)).to be false + expect(high.eql?(low)).to be false + end + + it 'compares against a String' do + expect(high.equal_value?('High')).to be true + expect(high.equal_value?('Medium')).to be false + end + + it 'compares against an Integer' do + expect(high.equal_value?(3)).to be true + expect(high.equal_value?(2)).to be false + end + end + end +end