import io import os from pathlib import Path import sys import time from types import SimpleNamespace from unittest import mock import pytest import requests from urllib3.response import HTTPResponse sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'app')) import scanner @pytest.fixture def transport(monkeypatch): state = SimpleNamespace(replies=[], calls=[], responses=[]) def send(adapter, request, **kwargs): state.calls.append((request, kwargs)) assert state.replies, 'unexpected offline HTTP request' reply = state.replies.pop(0) if isinstance(reply, Exception): raise reply status, headers, body = reply response = requests.Response() response.status_code = status response.headers.update(headers) response.url = request.url response.request = request response.raw = HTTPResponse(io.BytesIO(body), preload_content=False) state.responses.append(response) return response monkeypatch.setattr(requests.adapters.HTTPAdapter, 'send', send) for key in ('http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY'): monkeypatch.setenv(key, 'http://ambient.invalid:8080') monkeypatch.setenv('NO_PROXY', '') monkeypatch.setenv('no_proxy', '') monkeypatch.setenv('NETRC', str(Path(__file__).with_name('missing-netrc'))) return state @pytest.fixture def proxy_file(tmp_path, monkeypatch): path = tmp_path / 'routing-proxies.txt' path.write_text('http://configured.invalid:8080\n', encoding='ascii') monkeypatch.setattr(scanner.scan_config, 'api_proxy_enabled', True) monkeypatch.setattr(scanner.scan_config, 'api_proxy_file', str(path)) monkeypatch.setattr(scanner.scan_config, 'api_proxy_max_retries', 2) monkeypatch.setattr(scanner.scan_config, 'api_proxy_retry_delay', 0) monkeypatch.setattr(scanner.scan_config, 'api_proxy_timeout', 99) for name, value in (('path', None), ('mtime', None), ('entries', []), ('index', 0)): monkeypatch.setattr(scanner, '_api_proxy_cache_' + name, value) return path @pytest.mark.parametrize('scheme', ['http', 'https']) @pytest.mark.parametrize('stream', [False, True]) def test_direct_ignores_ambient_and_caller_proxies_across_redirects(transport, proxy_file, scheme, stream): transport.replies = [ (302, {'Location': 'https://second.invalid/payload'}, b''), (307, {'Location': 'http://third.invalid/payload'}, b''), (200, {}, b'complete artifact'), ] environment = dict(os.environ) with mock.patch.object(scanner, 'next_api_proxy', side_effect=AssertionError('direct selected proxy')): response = scanner.api_request( 'GET', scheme + '://first.invalid/payload', use_proxy=False, timeout=(3, 7), stream=stream, proxies={'https://second.invalid': 'http://caller.invalid:8080'}, ) assert len(response.history) == 2 if stream: assert not response._content_consumed assert not response.raw.closed assert b''.join(response.iter_content(3)) == b'complete artifact' else: assert response.content == b'complete artifact' response.close() for request, options in transport.calls: assert requests.utils.select_proxy(request.url, options['proxies']) is None assert options['proxies'] == {'no_proxy': '*'} assert options['timeout'] == (3, 7) assert 'Proxy-Authorization' not in request.headers assert dict(os.environ) == environment @pytest.mark.parametrize('verify,expected', [(None, 'fixture-ca.pem'), (True, 'fixture-ca.pem'), (False, False), ('explicit.pem', 'explicit.pem')]) def test_direct_preserves_requests_certificate_merge(transport, monkeypatch, verify, expected): monkeypatch.setenv('REQUESTS_CA_BUNDLE', 'fixture-ca.pem') transport.replies = [(200, {}, b'ok')] with scanner._direct_request('GET', 'https://fixture.invalid', verify=verify, cert=('cert.pem', 'key.pem')): pass options = transport.calls[0][1] assert options['verify'] == expected assert options['cert'] == ('cert.pem', 'key.pem') @pytest.mark.parametrize('missing', [False, True]) def test_configured_proxy_file_failure_never_falls_back_direct(transport, proxy_file, missing): if missing: proxy_file.unlink() else: proxy_file.write_text('# empty\n', encoding='ascii') with pytest.raises(scanner.ApiRequestError, match='no valid proxies'): scanner.api_request('GET', 'https://fixture.invalid') assert not transport.calls transport.replies = [(200, {}, b'direct')] with scanner.api_request('GET', 'https://fixture.invalid', use_proxy=False) as response: assert response.content == b'direct' def test_proxy_transport_failure_retries_file_proxy_without_direct_fallback(transport, proxy_file): transport.replies = [requests.exceptions.ProxyError('offline fixture')] * 2 with pytest.raises(scanner.ApiRequestError, match='after 2 attempt'): scanner.api_request('GET', 'https://fixture.invalid') assert len(transport.calls) == 2 for request, options in transport.calls: assert requests.utils.select_proxy(request.url, options['proxies']) == 'http://configured.invalid:8080' def test_metadata_file_proxy_survives_worker_no_proxy_and_redirects(transport, proxy_file, monkeypatch): monkeypatch.setenv('NO_PROXY', '*') monkeypatch.setenv('no_proxy', '*') transport.replies = [ (302, {'Location': 'https://second.invalid/metadata'}, b''), (200, {}, b'{}'), ] with scanner.api_request('GET', 'https://first.invalid/metadata') as response: assert response.content == b'{}' assert len(transport.calls) == 2 for request, options in transport.calls: assert requests.utils.select_proxy(request.url, options['proxies']) == 'http://configured.invalid:8080' @pytest.mark.parametrize('timeout,expected', [ (30, (5, 30)), (15, (5, 15)), (3, (3, 3)), ((2, 30), (2, 30)), ((12, 7), (5, 7)), ([9, 15], (5, 15)), ((None, 30), (5, 30)), ((2, None), (2, None)), (None, (5, 5)), ]) def test_proxy_connect_cap_keeps_caller_read_timeout(transport, proxy_file, monkeypatch, timeout, expected): monkeypatch.setattr(scanner.scan_config, 'api_proxy_timeout', 5) transport.replies = [(200, {}, b'{}')] with scanner.api_request('GET', 'https://fixture.invalid/metadata', timeout=timeout): pass request, options = transport.calls[0] assert options['timeout'] == expected assert requests.utils.select_proxy(request.url, options['proxies']) == 'http://configured.invalid:8080' def test_proxy_retry_budget_is_shared_and_clamps_read_timeout(transport, proxy_file, monkeypatch): clock = [0.0] waits = [] calls = [] monkeypatch.setattr(scanner.scan_config, 'api_proxy_timeout', 5) monkeypatch.setattr(scanner.time, 'monotonic', lambda: clock[0]) def request(*args, **kwargs): calls.append(kwargs) clock[0] += 7 raise requests.exceptions.ReadTimeout('offline fixture') def wait(seconds): waits.append(seconds) clock[0] += seconds monkeypatch.setattr(scanner.requests, 'request', request) monkeypatch.setattr(scanner, '_wait_or_raise_scan_slot_fatal', wait) with pytest.raises(scanner.ApiRequestError, match='deadline expired during retry'): scanner.api_request( 'GET', 'https://fixture.invalid/metadata', timeout=30, max_retries=3, retry_delay=5, deadline=20, ) assert [call['timeout'] for call in calls] == [(5, 20), (5, 8)] assert all(call['proxies']['https'] == 'http://configured.invalid:8080' for call in calls) assert waits == [5] def test_direct_retries_deadline_and_response_cleanup(transport, proxy_file): transport.replies = [(503, {}, b''), requests.exceptions.ConnectionError('offline'), (200, {}, b'ok')] with mock.patch.object(scanner, 'next_api_proxy', side_effect=AssertionError('direct selected proxy')): response = scanner.api_request( 'GET', 'https://fixture.invalid', use_proxy=False, stream=True, timeout=(2, 4), max_retries=3, retry_delay=0, deadline=time.monotonic() + 1, ) assert transport.responses[0].raw.closed assert not response.raw.closed response.close() assert len(transport.calls) == 3 assert all(0 < value <= 1 for _, options in transport.calls for value in options['timeout']) def test_direct_cancellation_closes_response_and_expired_deadline_sends_nothing(transport): transport.replies = [(200, {}, b'ok')] with mock.patch.object(scanner, '_raise_if_scan_slot_fatal', side_effect=[None, scanner.ScanSlotFatalError('offline')]), \ mock.patch.object(scanner._scan_slot_fatal_event, 'is_set', return_value=True): with pytest.raises(scanner.ScanSlotFatalError): scanner.api_request('GET', 'https://fixture.invalid', use_proxy=False, stream=True) assert transport.responses[0].raw.closed with pytest.raises(scanner.ApiRequestError, match='deadline expired'): scanner.api_request('GET', 'https://fixture.invalid', use_proxy=False, deadline=time.monotonic() - 1) assert len(transport.calls) == 1 @pytest.mark.parametrize('blob', [False, True]) def test_registry_metadata_uses_file_proxy_but_blob_and_401_retry_are_direct(transport, proxy_file, monkeypatch, blob): challenge = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io"' transport.replies = [ (401, {'WWW-Authenticate': challenge}, b''), (200, {}, b'{"token":"synthetic-token"}'), (200, {}, b'{"schemaVersion":2}'), ] monkeypatch.setattr(scanner, 'docker_token_manager', scanner.DockerTokenManager()) monkeypatch.setattr(scanner, '_require_docker_blob_url', lambda *a, **k: None) deadline = time.monotonic() + 10 digest = 'sha256:' + 'a' * 64 if blob: response, _ = scanner._docker_registry_blob_response('fixture/repo', digest, None, deadline) assert not response.raw.closed response.close() else: scanner.docker_registry_manifest('fixture/repo', digest, deadline=deadline) assert len(transport.calls) == 3 for index, (request, options) in enumerate(transport.calls): selected = requests.utils.select_proxy(request.url, options['proxies']) assert selected == (None if blob and index != 1 else 'http://configured.invalid:8080') assert options['stream'] is True def test_github_pool_forwards_content_optout_but_metadata_keeps_proxy(transport, proxy_file): transport.replies = [(200, {}, b'{"size":2,"encoding":"base64","content":"e30="}'), (200, {}, b'[]')] pool = scanner.GitHubTokenPool(token='synthetic-token') assert scanner.github_content_bytes({'url': 'https://fixture.invalid/content'}, pool) == b'{}' with pool.request('GET', 'https://fixture.invalid/commits'): pass assert [requests.utils.select_proxy(req.url, options['proxies']) for req, options in transport.calls] == [None, 'http://configured.invalid:8080'] def test_ci_download_direct_redirects_strip_credentials(transport, proxy_file, tmp_path, monkeypatch): transport.replies = [ (302, {'Location': 'https://other.invalid/artifact'}, b''), (200, {}, b'zip fixture'), ] monkeypatch.setattr(scanner, 'require_private_directory', lambda *a, **k: None) monkeypatch.setattr(scanner, 'reject_reparse_components', lambda *a: None) monkeypatch.setattr(scanner, 'harden_private_file', lambda *a: None) monkeypatch.setattr(scanner, 'private_file_ready', lambda *a: True) monkeypatch.setattr(scanner, 'durable_replace', os.replace) destination = tmp_path / 'artifact' outcome = scanner.download_to_file( 'https://fixture.invalid/artifact', str(destination), timeout=7, headers={'Authorization': 'synthetic-token', 'Private-Token': 'synthetic-token'}, ) assert outcome.ok and destination.read_bytes() == b'zip fixture' assert len(transport.calls) == 2 for request, options in transport.calls: assert requests.utils.select_proxy(request.url, options['proxies']) is None assert options['timeout'] == 7 assert 'Authorization' not in transport.calls[1][0].headers assert 'Private-Token' not in transport.calls[1][0].headers assert all(response.raw.closed for response in transport.responses) @pytest.mark.parametrize('wrapper', [scanner.github_api_get, scanner.gitlab_api_get]) def test_ci_metadata_wrappers_keep_configured_proxy_even_when_streamed(transport, proxy_file, wrapper): transport.replies = [(200, {}, b'{}')] with wrapper('https://fixture.invalid/metadata', stream=True): pass request, options = transport.calls[0] assert requests.utils.select_proxy(request.url, options['proxies']) == 'http://configured.invalid:8080' assert options['stream'] is True def test_pypi_bulk_index_is_direct_without_touching_a_database(transport, proxy_file, tmp_path, monkeypatch): path = tmp_path / 'mock-index' path.touch() connection = mock.Mock() connection.execute.return_value.fetchone.side_effect = [None, (1,)] monkeypatch.setattr(scanner, '_pypi_index_path', lambda: str(path)) monkeypatch.setattr(scanner.sqlite3, 'connect', mock.Mock(return_value=connection)) monkeypatch.setattr(scanner, 'harden_private_file', lambda *a: None) transport.replies = [(200, {}, b'fixture')] assert scanner.load_pypi_project_index(request_timeout=7) == str(path) request, options = transport.calls[0] assert requests.utils.select_proxy(request.url, options['proxies']) is None assert options['timeout'] == 7 and options['stream'] is True assert transport.responses[0].raw.closed connection.close.assert_called_once()