Files
truf-server/tests/test_api_proxy_routing.py
2026-09-30 20:30:56 +03:00

298 lines
14 KiB
Python

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'<a href="/simple/fixture/">fixture</a>')]
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()