Files
maigret/tests/test_checking.py
T
faeba71e9b Route the database auto-update through --proxy (#3109)
resolve_db_path() runs before the first site check and fetched db_meta.json
and data.json with bare requests.get() calls, so a run started with --proxy
still reached raw.githubusercontent.com from the real address once every 24
hours. Auto-update is on by default, and only HTTP_PROXY / HTTPS_PROXY were
honoured, by accident of requests reading them itself.

Thread the proxy into both requests and into force_update(), normalising the
scheme for PySocks the way the other two transports already are. An explicit
proxies mapping also wins over the environment, so NO_PROXY cannot bypass it.

When the proxy is unreachable requests raises and the existing handler
reports the update as failed and keeps the local database. There is
deliberately no retry without the proxy: that would put the leak back on the
failure path.

Fixes #3108

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 23:31:40 +03:00

2151 lines
72 KiB
Python

import asyncio
import re
import subprocess
import sys
import textwrap
from argparse import ArgumentTypeError
from unittest.mock import AsyncMock, Mock, patch
import pytest
from aiohttp.client_exceptions import ServerDisconnectedError
from curl_cffi import CurlError
from maigret import search
from maigret.activation import ParsingActivator
from maigret.checking import (
extract_ids_data,
parse_usernames,
update_results_info,
get_failed_sites,
timeout_check,
debug_response_logging,
process_site_result,
check_site_for_username,
make_site_result,
run_url_mutations,
_canonical_url,
_domain_tail,
MAX_MUTATIONS_PER_SITE,
CheckerMock,
self_check,
)
from maigret.error_detection import detect_error_page
from maigret.errors import CheckError
from maigret.result import MaigretCheckResult, MaigretCheckStatus
from maigret.sites import MaigretSite
def site_result_except(server, username, **kwargs):
query = f'id={username}'
server.expect_request('/url', query_string=query).respond_with_data(**kwargs)
@pytest.mark.slow
@pytest.mark.asyncio
async def test_checking_by_status_code(httpserver, local_test_db):
sites_dict = local_test_db.sites_dict
site_result_except(httpserver, 'claimed', status=200)
site_result_except(httpserver, 'unclaimed', status=404)
result = await search('claimed', site_dict=sites_dict, logger=Mock())
assert result['StatusCode']['status'].is_found() is True
result = await search('unclaimed', site_dict=sites_dict, logger=Mock())
assert result['StatusCode']['status'].is_found() is False
@pytest.mark.slow
@pytest.mark.asyncio
async def test_checking_by_message_positive_full(httpserver, local_test_db):
sites_dict = local_test_db.sites_dict
site_result_except(httpserver, 'claimed', response_data="user profile")
site_result_except(httpserver, 'unclaimed', response_data="404 not found")
result = await search('claimed', site_dict=sites_dict, logger=Mock())
assert result['Message']['status'].is_found() is True
result = await search('unclaimed', site_dict=sites_dict, logger=Mock())
assert result['Message']['status'].is_found() is False
@pytest.mark.slow
@pytest.mark.asyncio
async def test_checking_by_message_positive_part(httpserver, local_test_db):
sites_dict = local_test_db.sites_dict
site_result_except(httpserver, 'claimed', response_data="profile")
site_result_except(httpserver, 'unclaimed', response_data="404")
result = await search('claimed', site_dict=sites_dict, logger=Mock())
assert result['Message']['status'].is_found() is True
result = await search('unclaimed', site_dict=sites_dict, logger=Mock())
assert result['Message']['status'].is_found() is False
@pytest.mark.slow
@pytest.mark.asyncio
async def test_checking_by_message_negative(httpserver, local_test_db):
sites_dict = local_test_db.sites_dict
site_result_except(httpserver, 'claimed', response_data="")
site_result_except(httpserver, 'unclaimed', response_data="user 404")
result = await search('claimed', site_dict=sites_dict, logger=Mock())
assert result['Message']['status'].is_found() is False
result = await search('unclaimed', site_dict=sites_dict, logger=Mock())
assert result['Message']['status'].is_found() is True
@pytest.mark.slow
@pytest.mark.asyncio
async def test_checking_respects_site_encoding_override(httpserver, local_test_db):
# Endpoint lies about the charset (says utf-8) but serves GBK bytes.
# The site pins encoding=gbk, so the Chinese presenseStr must decode and match;
# without the override the GBK bytes decode as utf-8 mojibake and never match.
sites_dict = local_test_db.sites_dict
ct = 'text/html; charset=utf-8'
site_result_except(
httpserver, 'claimed', response_data='个人资料'.encode('gbk'), content_type=ct
)
site_result_except(
httpserver, 'unclaimed', response_data='页面不存在'.encode('gbk'), content_type=ct
)
result = await search('claimed', site_dict=sites_dict, logger=Mock())
assert result['GbkMessage']['status'].is_found() is True
result = await search('unclaimed', site_dict=sites_dict, logger=Mock())
assert result['GbkMessage']['status'].is_found() is False
# ---- Pure-function unit tests (no network) ----
def test_detect_error_page_site_specific():
err = detect_error_page(
"Please enable JavaScript to proceed",
200,
{"Please enable JavaScript to proceed": "Scraping protection"},
ignore_403=False,
)
assert err is not None
assert err.type == "Site-specific"
assert err.desc == "Scraping protection"
def test_detect_error_page_403():
err = detect_error_page("some body", 403, {}, ignore_403=False)
assert err is not None
assert err.type == "Access denied"
def test_detect_error_page_403_ignored():
# XenForo engine uses ignore403 because member-not-found also returns 403
assert detect_error_page("not found body", 403, {}, ignore_403=True) is None
def test_detect_error_page_999_linkedin():
# LinkedIn returns 999 on bot suspicion, for real and made-up profiles
# alike. It has to be an error: passing it through made every profile
# look free to a blocked client.
err = detect_error_page("", 999, {}, ignore_403=False)
assert err is not None
assert err.type == "Access denied"
assert "999" in err.desc
def test_detect_error_page_500():
err = detect_error_page("", 503, {}, ignore_403=False)
assert err is not None
assert err.type == "Server"
assert "503" in err.desc
def test_detect_error_page_ok():
assert detect_error_page("hello world", 200, {}, ignore_403=False) is None
def test_detect_error_page_instagram_login_wall():
"""Regression for #11: when Instagram serves the login wall (typically the
response after rate-limiting an unauthenticated client), the JSON state
contains `"routePath":"\\/"` (root path) rather than a username route. The
Instagram entry in data.json carries this marker in `errors` so the result
surfaces as UNKNOWN instead of a false AVAILABLE.
"""
instagram_errors = {
"Login • Instagram": "Login required",
'"routePath":"\\/"': "Login required (rate-limited or session blocked)",
}
login_wall_html = '...{"routePath":"\\/"},"timeSpent":...'
err = detect_error_page(login_wall_html, 200, instagram_errors, ignore_403=False)
assert err is not None
assert err.type == "Site-specific"
assert "rate-limited" in err.desc
def test_detect_error_page_instagram_marker_no_false_positive_on_profile():
"""The login-wall marker must NOT match a real profile page. On a claimed
user page, `routePath` carries the user-route template
(`"routePath":"\\/{username}\\/..."`); the closing-quote form
`"routePath":"\\/"` only appears on the login wall.
"""
instagram_errors = {
'"routePath":"\\/"': "Login required (rate-limited or session blocked)",
}
profile_html = (
'foo,"routePath":"\\/{username}\\/{?tab}\\/{?view_type}\\/",bar'
)
err = detect_error_page(profile_html, 200, instagram_errors, ignore_403=False)
assert err is None
def test_parse_usernames_single_username():
logger = Mock()
result = parse_usernames({"profile_username": "alice"}, logger)
assert result == {"alice": "username"}
def test_parse_usernames_list_of_usernames():
logger = Mock()
result = parse_usernames({"other_usernames": "['alice', 'bob']"}, logger)
assert result == {"alice": "username", "bob": "username"}
def test_parse_usernames_malformed_list():
logger = Mock()
result = parse_usernames({"other_usernames": "not-a-list"}, logger)
# should swallow the error and just return empty
assert result == {}
assert logger.warning.called
def test_parse_usernames_rejects_url_value():
"""Regression for #1403: extractors sometimes return a URL under a *_username
key; that URL must not be fed back as a candidate username."""
logger = Mock()
result = parse_usernames(
{"instagram_username": "https://instagram.com/zuck"}, logger
)
assert result == {}
def test_parse_usernames_rejects_email_value():
"""Regression for #1403: e.g. socid_extractor's 'your_username' returns an
email under a key matching the username heuristic."""
logger = Mock()
result = parse_usernames({"your_username": "alice@example.com"}, logger)
assert result == {}
def test_parse_usernames_filters_urls_inside_list():
logger = Mock()
result = parse_usernames(
{"other_usernames": "['alice', 'https://example.com/bob']"}, logger
)
# 'alice' should survive; the URL should be dropped.
assert result == {"alice": "username"}
def test_parse_usernames_supported_id():
logger = Mock()
from maigret.checking import SUPPORTED_IDS
if SUPPORTED_IDS:
key = next(iter(SUPPORTED_IDS))
result = parse_usernames({key: "some_value"}, logger)
assert result.get("some_value") == key
def test_parse_usernames_rejects_implausible_bare_username():
"""Regression for #1403: the bare 'username' key is itself in SUPPORTED_IDS,
so a URL/email/path value under it must still be dropped. Previously the
unconditional 'if k in SUPPORTED_IDS' branch re-added the value that the
plausibility check had just rejected, reopening the false-error cascade."""
logger = Mock()
from maigret.checking import SUPPORTED_IDS
assert "username" in SUPPORTED_IDS # guards the premise of this test
assert parse_usernames({"username": "https://instagram.com/zuck"}, logger) == {}
assert parse_usernames({"username": "alice@example.com"}, logger) == {}
assert parse_usernames({"username": "user/alice"}, logger) == {}
# a plausible bare username still survives
assert parse_usernames({"username": "alice"}, logger) == {"alice": "username"}
def test_update_results_info_links():
info = {"username": "test"}
result = update_results_info(
info,
{"links": "['https://example.com/a', 'https://example.com/b']", "website": "https://example.com/w"},
{"alice": "username"},
)
assert result["ids_usernames"] == {"alice": "username"}
assert "https://example.com/w" in result["ids_links"]
assert "https://example.com/a" in result["ids_links"]
def test_update_results_info_no_website():
info = {}
result = update_results_info(info, {"links": "[]"}, {})
assert result["ids_links"] == []
def test_extract_ids_data_bad_html_returns_empty():
logger = Mock()
# Random HTML should not raise — returns {} if nothing matches
out = extract_ids_data("<html><body>nothing special</body></html>", logger, Mock(name="Site"))
assert isinstance(out, dict)
def test_get_failed_sites_filters_permanent_errors():
# Temporary errors (Request timeout, Connecting failure, etc.) are retryable → returned.
# Permanent ones (Captcha, Access denied, etc.) and results without error → filtered out.
good_status = MaigretCheckResult("u", "S1", "https://s1", MaigretCheckStatus.CLAIMED)
timeout_err = MaigretCheckResult(
"u", "S2", "https://s2", MaigretCheckStatus.UNKNOWN,
error=CheckError("Request timeout", "slow server"),
)
captcha_err = MaigretCheckResult(
"u", "S3", "https://s3", MaigretCheckStatus.UNKNOWN,
error=CheckError("Captcha", "Cloudflare"),
)
results = {
"S1": {"status": good_status},
"S2": {"status": timeout_err},
"S3": {"status": captcha_err},
"S4": {}, # no status at all
}
failed = get_failed_sites(results)
# Only the temporary-error site is retry-worthy
assert failed == ["S2"]
def test_timeout_check_valid():
assert timeout_check("2.5") == 2.5
assert timeout_check("30") == 30.0
def test_timeout_check_invalid():
with pytest.raises(ArgumentTypeError):
timeout_check("abc")
with pytest.raises(ArgumentTypeError):
timeout_check("0")
with pytest.raises(ArgumentTypeError):
timeout_check("-1")
def test_debug_response_logging_writes(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
debug_response_logging("https://example.com", "<html>hi</html>", 200, None)
out = (tmp_path / "debug.log").read_text()
assert "https://example.com" in out
assert "200" in out
def test_debug_response_logging_no_response(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
debug_response_logging("https://example.com", None, None, CheckError("Timeout"))
out = (tmp_path / "debug.log").read_text()
assert "No response" in out
def test_debug_response_logging_writes_non_ascii_page(tmp_path):
"""Debug mode dumps whole response bodies, and plenty of sites answer in
Cyrillic or CJK. Writing those with the locale codepage (cp1252 on a
default Windows install) raised UnicodeEncodeError from inside the check.
Run it under -X warn_default_encoding so dropping the explicit encoding
fails here as well, not only on a non-utf-8 machine.
"""
probe = tmp_path / "debug_log_probe.py"
probe.write_text(
textwrap.dedent(
"""
from maigret.checking import debug_response_logging
debug_response_logging(
"https://example.com/привет",
"<html>Привет 日本語</html>",
200,
None,
)
print("OK")
"""
),
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
"-X",
"warn_default_encoding",
"-W",
"error::EncodingWarning",
str(probe),
],
cwd=str(tmp_path),
capture_output=True,
encoding="utf-8",
errors="replace",
)
assert result.returncode == 0, (
f"stdout={result.stdout!r} stderr={result.stderr!r}"
)
out = (tmp_path / "debug.log").read_bytes().decode("utf-8")
assert "<html>Привет 日本語</html>" in out
def _make_site(data_overrides=None):
base = {
"url": "https://x/{username}",
"urlMain": "https://x",
"checkType": "status_code",
"usernameClaimed": "a",
"usernameUnclaimed": "b",
}
if data_overrides:
base.update(data_overrides)
return MaigretSite("TestSite", base)
def test_process_site_result_no_response_returns_info():
site = _make_site()
info = {"username": "a", "parsing_enabled": False, "url_user": "https://x/a"}
out = process_site_result(None, Mock(), Mock(), info, site)
assert out is info
def test_process_site_result_status_already_set():
site = _make_site()
pre = MaigretCheckResult("a", "S", "u", MaigretCheckStatus.ILLEGAL)
info = {"username": "a", "parsing_enabled": False, "status": pre, "url_user": "u"}
# Since status is already set, function returns without changes
out = process_site_result(("<html/>", 200, None), Mock(), Mock(), info, site)
assert out["status"] is pre
def test_process_site_result_status_code_claimed():
site = _make_site({"checkType": "status_code"})
info = {"username": "a", "parsing_enabled": False, "url_user": "https://x/a"}
out = process_site_result(("<html/>", 200, None), Mock(), Mock(), info, site)
assert out["status"].status == MaigretCheckStatus.CLAIMED
assert out["http_status"] == 200
def test_process_site_result_status_code_available():
site = _make_site({"checkType": "status_code"})
info = {"username": "a", "parsing_enabled": False, "url_user": "https://x/a"}
out = process_site_result(("<html/>", 404, None), Mock(), Mock(), info, site)
assert out["status"].status == MaigretCheckStatus.AVAILABLE
def test_process_site_result_message_claimed():
site = _make_site({
"checkType": "message",
"presenseStrs": ["profile-name"],
"absenceStrs": ["not found"],
})
info = {"username": "a", "parsing_enabled": False, "url_user": "https://x/a"}
out = process_site_result(("<div class='profile-name'>Alice</div>", 200, None), Mock(), Mock(), info, site)
assert out["status"].status == MaigretCheckStatus.CLAIMED
def test_process_site_result_message_available_by_absence():
site = _make_site({
"checkType": "message",
"presenseStrs": ["profile-name"],
"absenceStrs": ["not found"],
})
info = {"username": "a", "parsing_enabled": False, "url_user": "https://x/a"}
out = process_site_result(("<h1>not found</h1> profile-name too", 200, None), Mock(), Mock(), info, site)
# absence marker wins even if presence marker also appears
assert out["status"].status == MaigretCheckStatus.AVAILABLE
def test_process_site_result_message_non_ascii_username_no_match():
"""#2633: non-ASCII (e.g. Chinese) username with empty presenseStrs and
response that doesn't contain the username → NOT claimed (avoid false positive)."""
site = _make_site({
"checkType": "message",
"presenseStrs": [],
"absenceStrs": [],
})
info = {"username": "快嘴摩卡酱", "parsing_enabled": False, "url_user": "https://x/%E5%BF%AB"}
out = process_site_result(
("<html><title>Generic page</title><p>some content</p></html>", 200, None),
Mock(), Mock(), info, site,
)
# Username not in response body → should NOT be CLAIMED
assert out["status"].status != MaigretCheckStatus.CLAIMED
def test_process_site_result_message_non_ascii_username_with_match():
"""#2633: non-ASCII username that DOES appear in response → normal CLAIMED."""
site = _make_site({
"checkType": "message",
"presenseStrs": [],
"absenceStrs": [],
})
info = {"username": "快嘴摩卡酱", "parsing_enabled": False, "url_user": "https://x/%E5%BF%AB"}
out = process_site_result(
("<html><title>快嘴摩卡酱的博客</title><p>欢迎来到快嘴摩卡酱的主页</p></html>", 200, None),
Mock(), Mock(), info, site,
)
# Username IS in response → normal CLAIMED behavior (with empty presenseStrs)
assert out["status"].status == MaigretCheckStatus.CLAIMED
def _process_default_site(site, body, status_code=200, username="random"):
info = {
"username": username,
"parsing_enabled": False,
"url_user": site.url.replace("{username}", username),
}
return process_site_result((body, status_code, None), Mock(), Mock(), info, site)
def test_hackernews_requires_profile_marker(default_db):
site = default_db.sites_dict["HackerNews"]
claimed = _process_default_site(
site,
'{"id":"blue","created":1270058024,"karma":1}',
username="blue",
)
missing = _process_default_site(site, "null", username="random-hn-user")
generic = _process_default_site(site, "Sorry.", username="random-hn-user")
assert claimed["status"].status == MaigretCheckStatus.CLAIMED
assert missing["status"].status == MaigretCheckStatus.AVAILABLE
assert generic["status"].status == MaigretCheckStatus.AVAILABLE
def test_rajce_requires_profile_marker(default_db):
site = default_db.sites_dict["Rajce.net"]
claimed = _process_default_site(
site,
'<script>var settings = {"user":{"username":"blue"}}</script>',
username="blue",
)
missing = _process_default_site(
site,
"<title>Uživatel neexistuje</title>",
status_code=410,
username="random-rajce-user",
)
generic = _process_default_site(
site,
"<html><title>Rajce.net</title></html>",
username="random-rajce-user",
)
assert claimed["status"].status == MaigretCheckStatus.CLAIMED
assert missing["status"].status == MaigretCheckStatus.AVAILABLE
assert generic["status"].status == MaigretCheckStatus.AVAILABLE
def test_process_site_result_with_error_is_unknown():
site = _make_site({"checkType": "status_code"})
info = {"username": "a", "parsing_enabled": False, "url_user": "https://x/a"}
resp = ("body", 403, CheckError("Captcha", "Cloudflare"))
out = process_site_result(resp, Mock(), Mock(), info, site)
assert out["status"].status == MaigretCheckStatus.UNKNOWN
assert out["status"].error is not None
def test_process_site_result_error_context_uses_instance():
# Regression: context must render the CheckError instance, not the class.
site = _make_site({"checkType": "status_code"})
info = {"username": "a", "parsing_enabled": False, "url_user": "https://x/a"}
err = CheckError("Request timeout", "slow server")
out = process_site_result(("body", 0, err), Mock(), Mock(), info, site)
assert out["status"].context == "Request timeout error: slow server"
assert "class" not in out["status"].context
@pytest.mark.asyncio
@pytest.mark.parametrize("protocol", ["tor", "i2p", "dns"])
async def test_check_site_for_username_skips_mock_protocol_checkers(protocol):
site = _make_site({"protocol": protocol})
class TrackingChecker(CheckerMock):
checked = False
async def check(self):
self.checked = True
return await super().check()
checker = TrackingChecker()
options = {
"parsing": False,
"cookie_jar": None,
"forced": True,
"id_type": "username",
"timeout": 3,
"proxy": None,
"checkers": {protocol: checker},
}
query_notify = Mock()
_, result = await check_site_for_username(
site,
"a",
options,
Mock(),
query_notify,
)
status = result["status"]
assert status.status == MaigretCheckStatus.ILLEGAL
assert status.error.type == "Skipped"
assert status.error.desc == f"no {protocol} gateway configured"
assert "Connection lost" not in str(status.error)
assert checker.checked is False
query_notify.update.assert_called_once_with(status, site.similar_search)
@pytest.mark.asyncio
async def test_check_site_for_username_preserves_clearweb_connection_lost():
site = _make_site()
class FailingChecker:
prepared = False
checked = False
def prepare(self, *args, **kwargs):
self.prepared = True
return None
async def check(self):
self.checked = True
return "", 0, CheckError("Connection lost")
checker = FailingChecker()
options = {
"parsing": False,
"cookie_jar": None,
"forced": True,
"id_type": "username",
"timeout": 3,
"proxy": None,
"checkers": {"": checker},
}
_, result = await check_site_for_username(
site,
"a",
options,
Mock(),
Mock(),
)
assert checker.prepared is True
assert checker.checked is True
assert result["status"].status == MaigretCheckStatus.UNKNOWN
assert result["status"].error.type == "Connection lost"
@pytest.mark.asyncio
async def test_check_site_for_username_awaits_activation_before_retry(monkeypatch):
site = _make_site({
"checkType": "status_code",
"headers": {"X-Initial": "1"},
"activation": {
"method": "test_async",
"marks": ["NEEDS_ACTIVATION"],
},
"protocol": "https",
})
class FakeChecker:
def __init__(self):
self.calls = 0
self.prepared_headers = []
self.url = None
self.headers = None
self.allow_redirects = True
self.timeout = 0
self.method = "get"
self.payload = None
def prepare(
self,
url,
headers=None,
allow_redirects=True,
timeout=0,
method="get",
payload=None,
encoding=None,
):
self.url = url
self.headers = headers
self.allow_redirects = allow_redirects
self.timeout = timeout
self.method = method
self.payload = payload
self.prepared_headers.append(dict(headers or {}))
return None
async def check(self):
self.calls += 1
if self.calls == 1:
return "NEEDS_ACTIVATION", 200, None
return "activated", 200, None
async def activate(site, logger, **kwargs):
await asyncio.sleep(0)
site.headers["X-Activated"] = "yes"
checker = FakeChecker()
monkeypatch.setattr(
ParsingActivator,
"test_async",
staticmethod(activate),
raising=False,
)
options = {
"parsing": False,
"cookie_jar": None,
"forced": True,
"id_type": "username",
"timeout": 3,
"proxy": None,
"checkers": {"https": checker},
}
_, result = await check_site_for_username(
site,
"a",
options,
Mock(),
Mock(),
)
assert checker.calls == 2
assert checker.prepared_headers[-1]["X-Activated"] == "yes"
assert result["status"].status == MaigretCheckStatus.CLAIMED
@pytest.mark.asyncio
async def test_concurrent_activation_uses_independent_checkers(monkeypatch):
instances = []
class FakeChecker:
def __init__(self):
self.calls = 0
self.prepared_urls = []
self.url = None
self.headers = None
self.allow_redirects = True
self.timeout = 0
self.method = "get"
self.payload = None
instances.append(self)
def prepare(
self,
url,
headers=None,
allow_redirects=True,
timeout=0,
method="get",
payload=None,
encoding=None,
):
self.url = url
self.headers = headers
self.allow_redirects = allow_redirects
self.timeout = timeout
self.method = method
self.payload = payload
self.prepared_urls.append(url)
return None
async def check(self):
await asyncio.sleep(0)
self.calls += 1
if self.calls == 1:
return "NEEDS_ACTIVATION", 200, None
return "activated", 200, None
async def activate(site, logger, **kwargs):
await asyncio.sleep(0)
site.headers["X-Activated"] = site.name
monkeypatch.setattr(
ParsingActivator,
"test_async",
staticmethod(activate),
raising=False,
)
options = {
"parsing": False,
"cookie_jar": None,
"forced": True,
"id_type": "username",
"timeout": 3,
"proxy": None,
"checkers": {"https": FakeChecker},
}
first = _make_site({
"url": "https://x/one/{username}",
"urlMain": "https://x",
"checkType": "status_code",
"activation": {"method": "test_async", "marks": ["NEEDS_ACTIVATION"]},
"protocol": "https",
})
first.name = "First"
second = _make_site({
"url": "https://x/two/{username}",
"urlMain": "https://x",
"checkType": "status_code",
"activation": {"method": "test_async", "marks": ["NEEDS_ACTIVATION"]},
"protocol": "https",
})
second.name = "Second"
await asyncio.gather(
check_site_for_username(first, "a", options, Mock(), Mock()),
check_site_for_username(second, "a", options, Mock(), Mock()),
)
assert len(instances) == 2
assert [checker.prepared_urls for checker in instances] == [
["https://x/one/a", "https://x/one/a"],
["https://x/two/a", "https://x/two/a"],
]
# ---- CurlCffiChecker: TLS impersonation header sanitisation ----
class _FakeCurlResponse:
def __init__(self, text="ok", status_code=200):
self.text = text
self.status_code = status_code
class _FakeCurlSession:
"""Captures constructor + .get/.post/.head call kwargs for assertions."""
last_method = None
last_kwargs = None
last_init_kwargs = None
def __init__(self, **kwargs):
type(self).last_init_kwargs = kwargs
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, **kwargs):
type(self).last_method = 'get'
type(self).last_kwargs = kwargs
return _FakeCurlResponse()
async def post(self, **kwargs):
type(self).last_method = 'post'
type(self).last_kwargs = kwargs
return _FakeCurlResponse()
async def head(self, **kwargs):
type(self).last_method = 'head'
type(self).last_kwargs = kwargs
return _FakeCurlResponse()
@pytest.fixture
def fake_curl_cffi(monkeypatch):
"""Replace CurlCffiAsyncSession with a recorder. Resets capture between tests."""
from maigret import checking
_FakeCurlSession.last_method = None
_FakeCurlSession.last_kwargs = None
_FakeCurlSession.last_init_kwargs = None
monkeypatch.setattr(checking, 'CurlCffiAsyncSession', _FakeCurlSession)
return _FakeCurlSession
@pytest.mark.asyncio
async def test_curl_cffi_strips_random_user_agent_to_let_impersonation_drive_ua(fake_curl_cffi):
"""Regression: maigret used to forward `get_random_user_agent()` (often Chrome 91)
to curl_cffi alongside `impersonate="chrome"` (Chrome 131 TLS). Cloudflare composite
bot scoring rejects the resulting "Chrome 91 UA + Chrome 131 TLS" combo with a JS
challenge. The fix strips User-Agent and Connection from the headers passed to
curl_cffi so the impersonation default UA wins.
"""
from maigret.checking import CurlCffiChecker
checker = CurlCffiChecker(logger=Mock(), browser_emulate='chrome')
checker.prepare(
url='https://example.com/u/test',
headers={
"User-Agent": "Mozilla/5.0 ... Chrome/91.0.4472.124 ...", # maigret default
"Connection": "close", # maigret default
},
allow_redirects=True,
timeout=10,
method='get',
)
await checker.check()
sent = fake_curl_cffi.last_kwargs
assert fake_curl_cffi.last_method == 'get'
assert sent['impersonate'] == 'chrome'
# The whole point of the fix: random UA must not leak through.
assert sent['headers'] is None or 'User-Agent' not in sent['headers']
assert sent['headers'] is None or 'user-agent' not in {k.lower() for k in sent['headers']}
# Connection: close also stripped (interferes with impersonation defaults).
assert sent['headers'] is None or 'Connection' not in sent['headers']
@pytest.mark.asyncio
async def test_curl_cffi_preserves_site_specific_headers(fake_curl_cffi):
"""Site-specific headers (e.g. Content-Type for POST APIs, auth tokens, cookies)
must survive the User-Agent strip — only UA and Connection are removed.
"""
from maigret.checking import CurlCffiChecker
checker = CurlCffiChecker(logger=Mock(), browser_emulate='chrome')
checker.prepare(
url='https://example.com/api',
headers={
"User-Agent": "Mozilla/5.0 random",
"Connection": "close",
"Content-Type": "application/json",
"X-Csrf-Token": "abc123",
},
allow_redirects=True,
timeout=10,
method='get',
)
await checker.check()
sent_headers = fake_curl_cffi.last_kwargs['headers']
assert sent_headers is not None
assert sent_headers.get("Content-Type") == "application/json"
assert sent_headers.get("X-Csrf-Token") == "abc123"
# Sanity: stripped pair is gone
assert "User-Agent" not in sent_headers
assert "Connection" not in sent_headers
@pytest.mark.asyncio
async def test_curl_cffi_handles_empty_headers(fake_curl_cffi):
"""No headers at all → headers kwarg is None (not an empty dict that could confuse
curl_cffi's impersonation header injection)."""
from maigret.checking import CurlCffiChecker
checker = CurlCffiChecker(logger=Mock(), browser_emulate='chrome')
checker.prepare(
url='https://example.com/u/test',
headers=None,
allow_redirects=True,
timeout=10,
method='get',
)
await checker.check()
assert fake_curl_cffi.last_kwargs['headers'] is None
assert fake_curl_cffi.last_kwargs['impersonate'] == 'chrome'
@pytest.mark.asyncio
async def test_curl_cffi_strips_ua_for_post_too(fake_curl_cffi):
"""The same UA-strip must apply on POST (e.g. Discord-style POST username probes
with `tls_fingerprint`)."""
from maigret.checking import CurlCffiChecker
checker = CurlCffiChecker(logger=Mock(), browser_emulate='chrome')
checker.prepare(
url='https://example.com/api/check',
headers={
"User-Agent": "Mozilla/5.0 random",
"Content-Type": "application/json",
},
allow_redirects=True,
timeout=10,
method='post',
payload={"username": "test"},
)
await checker.check()
sent = fake_curl_cffi.last_kwargs
assert fake_curl_cffi.last_method == 'post'
assert sent['json'] == {"username": "test"}
assert "User-Agent" not in sent['headers']
assert sent['headers'].get("Content-Type") == "application/json"
@pytest.mark.asyncio
async def test_curl_cffi_forwards_proxy_to_async_session(fake_curl_cffi):
"""Regression for #2648: when --proxy is set, the proxy URL must be
forwarded to curl_cffi's AsyncSession via the `proxies` kwarg on the
session constructor. Otherwise sites with `tls_fingerprint` protection
(Instagram, Reddit, SoundCloud, Threads, …) silently bypass the
configured proxy and connect direct.
"""
from maigret.checking import CurlCffiChecker
proxy = "http://user:pass@proxy.example.com:8080"
checker = CurlCffiChecker(logger=Mock(), browser_emulate='chrome', proxy=proxy)
checker.prepare(
url='https://example.com/u/test',
headers=None,
allow_redirects=True,
timeout=10,
method='get',
)
await checker.check()
init = fake_curl_cffi.last_init_kwargs
assert init is not None, "CurlCffiAsyncSession was never constructed"
# curl_cffi expects the standard requests-style {scheme: url} mapping
assert init.get('proxies') == {'http': proxy, 'https': proxy}
@pytest.mark.asyncio
async def test_curl_cffi_no_proxy_omits_proxies_kwarg(fake_curl_cffi):
"""Counterpart to the proxy-forwarding test: when no proxy is configured,
the `proxies` kwarg must NOT appear on the AsyncSession constructor.
Passing `proxies=None` or an empty mapping would let curl_cffi inherit
the process-wide HTTPS_PROXY env var unintentionally.
"""
from maigret.checking import CurlCffiChecker
checker = CurlCffiChecker(logger=Mock(), browser_emulate='chrome')
checker.prepare(
url='https://example.com/u/test',
headers=None,
allow_redirects=True,
timeout=10,
method='get',
)
await checker.check()
init = fake_curl_cffi.last_init_kwargs
assert init is not None, "CurlCffiAsyncSession was never constructed"
assert 'proxies' not in init
class _RaisingThenOkCurlSession:
"""First .get() call raises CurlError, simulating a rotating proxy's
CONNECT tunnel 502ing or dropping the TLS handshake mid-way; the retry
goes out through a fresh connection and succeeds."""
calls = 0
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, **kwargs):
type(self).calls += 1
if type(self).calls == 1:
raise CurlError(
"Failed to perform, curl: (56) CONNECT tunnel failed, response 502.", 56
)
return _FakeCurlResponse()
class _AlwaysRaisingCurlSession:
calls = 0
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, **kwargs):
type(self).calls += 1
raise CurlError(
"Failed to perform, curl: (56) CONNECT tunnel failed, response 502.", 56
)
@pytest.mark.asyncio
async def test_curl_cffi_retries_once_on_connection_error(monkeypatch):
from maigret import checking
from maigret.checking import CurlCffiChecker
_RaisingThenOkCurlSession.calls = 0
monkeypatch.setattr(checking, 'CurlCffiAsyncSession', _RaisingThenOkCurlSession)
checker = CurlCffiChecker(logger=Mock(), browser_emulate='chrome')
checker.prepare(
url='https://example.com/u/test',
headers=None,
allow_redirects=True,
timeout=10,
method='get',
)
text, status, error = await checker.check()
assert _RaisingThenOkCurlSession.calls == 2
assert error is None
assert status == 200
assert text == 'ok'
@pytest.mark.asyncio
async def test_curl_cffi_gives_up_as_connecting_failure_after_retry(monkeypatch):
from maigret import checking
from maigret.checking import CurlCffiChecker
_AlwaysRaisingCurlSession.calls = 0
monkeypatch.setattr(checking, 'CurlCffiAsyncSession', _AlwaysRaisingCurlSession)
checker = CurlCffiChecker(logger=Mock(), browser_emulate='chrome')
checker.prepare(
url='https://example.com/u/test',
headers=None,
allow_redirects=True,
timeout=10,
method='get',
)
text, status, error = await checker.check()
assert _AlwaysRaisingCurlSession.calls == 2 # initial attempt + one retry, then gives up
assert error.type == 'Connecting failure'
# -----------------------------------------------------------------------------
# DNS-resolver selection (issue #2688). When --dns-resolver=threaded is passed,
# SimpleAiohttpChecker must build the TCPConnector with an explicit
# ThreadedResolver instead of letting aiohttp default to AsyncResolver
# (aiodns/c-ares), which fails on Windows / VPN / corporate networks with
# "Could not contact DNS servers".
# -----------------------------------------------------------------------------
def _capture_tcpconnector(monkeypatch):
"""Replace aiohttp.TCPConnector with a constructor-recorder. Each call
appends its kwargs to the returned list. ClientSession will then receive
a never-used dummy connector — we don't actually want to open sockets in
tests."""
from maigret import checking
captured = []
class _DummyConnector:
def __init__(self, *args, **kwargs):
captured.append(kwargs)
# aiohttp's ClientSession expects these on its connector
self._loop = None
self.closed = False
async def close(self):
pass
@property
def force_close(self):
return False
monkeypatch.setattr(checking, 'TCPConnector', _DummyConnector)
return captured
@pytest.mark.asyncio
async def test_dns_resolver_threaded_passes_threaded_resolver_to_tcpconnector(monkeypatch):
"""Issue #2688: with dns_resolver='threaded', the connector must be built
with a ThreadedResolver so DNS resolution goes through the OS, not aiodns."""
from maigret.checking import SimpleAiohttpChecker
from aiohttp.resolver import ThreadedResolver
captured = _capture_tcpconnector(monkeypatch)
checker = SimpleAiohttpChecker(logger=Mock(), dns_resolver='threaded')
checker.prepare(url='https://example.com/u/test')
# The ClientSession context manager will try to use _DummyConnector and
# then make an HTTP request — we don't care about the request itself,
# only about the TCPConnector kwargs that were captured before any I/O.
try:
await checker.check()
except Exception:
pass
assert captured, "TCPConnector was never constructed — check() bailed out earlier"
init_kwargs = captured[0]
assert 'resolver' in init_kwargs, "dns_resolver='threaded' did not forward a resolver to TCPConnector"
assert isinstance(init_kwargs['resolver'], ThreadedResolver)
@pytest.mark.asyncio
async def test_dns_resolver_async_omits_resolver_kwarg(monkeypatch):
"""Default ('async') must NOT pass an explicit resolver, so aiohttp uses
its DefaultResolver (AsyncResolver via aiodns). Passing resolver=None
would override the smart default with an invalid value."""
from maigret.checking import SimpleAiohttpChecker
captured = _capture_tcpconnector(monkeypatch)
checker = SimpleAiohttpChecker(logger=Mock()) # default
checker.prepare(url='https://example.com/u/test')
try:
await checker.check()
except Exception:
pass
assert captured
init_kwargs = captured[0]
assert 'resolver' not in init_kwargs, (
"Default dns_resolver='async' must not pass a resolver kwarg — let "
"aiohttp pick DefaultResolver"
)
# -----------------------------------------------------------------------------
# cookie_jar forwarding (issue #2666). SimpleAiohttpChecker.check() builds the
# ClientSession with `cookie_jar=self.cookie_jar if self.cookie_jar else None`,
# but the path was flagged `# TODO: tests` because nothing asserted the jar
# passed to the checker actually reached the outgoing ClientSession.
# -----------------------------------------------------------------------------
def _capture_clientsession(monkeypatch):
"""Replace aiohttp.ClientSession with a constructor-recorder so we can
assert on the kwargs it received (cookie_jar in particular) without
opening any sockets. Mirrors the _capture_tcpconnector pattern above."""
from maigret import checking
captured = []
class _DummySession:
def __init__(self, *args, **kwargs):
captured.append(kwargs)
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def close(self):
pass
monkeypatch.setattr(checking, 'ClientSession', _DummySession)
return captured
@pytest.mark.asyncio
async def test_cookie_jar_forwarded_to_clientsession(monkeypatch):
"""Issue #2666: the cookie_jar passed to SimpleAiohttpChecker must reach
ClientSession(cookie_jar=...) on the outgoing request. Reuses the
constructor-capture pattern from the DNS-resolver tests above.
A sentinel object stands in for the real aiohttp.CookieJar: check() only
does `self.cookie_jar if self.cookie_jar else None`, so any truthy object
proves the forwarding. Avoiding CookieJar() also sidesteps its event-loop
requirement under pytest-asyncio, keeping this a pure unit test."""
from maigret.checking import SimpleAiohttpChecker
captured = _capture_clientsession(monkeypatch)
_capture_tcpconnector(monkeypatch) # keep check() from opening a socket
sentinel_jar = object() # any truthy stand-in for the CookieJar
checker = SimpleAiohttpChecker(logger=Mock(), cookie_jar=sentinel_jar)
checker.prepare(url='https://example.com/u/test')
try:
await checker.check()
except Exception:
pass
assert captured, "ClientSession was never constructed — check() bailed out earlier"
init_kwargs = captured[0]
assert init_kwargs.get('cookie_jar') is sentinel_jar, (
"cookie_jar passed to SimpleAiohttpChecker was not forwarded to ClientSession"
)
@pytest.mark.asyncio
async def test_no_cookie_jar_passes_none_to_clientsession(monkeypatch):
"""Counterpart to the above: with no cookie_jar configured, ClientSession
must receive `cookie_jar=None` (the `else None` branch), not the default
aiohttp CookieJar — maigret manages cookie persistence explicitly via
import_aiohttp_cookies and must not let aiohttp silently persist cookies."""
from maigret.checking import SimpleAiohttpChecker
captured = _capture_clientsession(monkeypatch)
_capture_tcpconnector(monkeypatch)
checker = SimpleAiohttpChecker(logger=Mock()) # no cookie_jar
checker.prepare(url='https://example.com/u/test')
try:
await checker.check()
except Exception:
pass
assert captured, "ClientSession was never constructed — check() bailed out earlier"
init_kwargs = captured[0]
assert init_kwargs.get('cookie_jar') is None, (
"No cookie_jar configured, but ClientSession received a non-None cookie_jar"
)
# -- --enrich / url_mutations --------------------------------------------------
def test_canonical_url_ignores_query_and_trailing_slash():
"""(scheme, host, path) tuple is what dedup compares."""
a = _canonical_url("https://leetcode.com/graphql/")
b = _canonical_url("https://leetcode.com/graphql?query=abc&variables=xyz")
assert a == b
def test_canonical_url_lowercases_scheme_and_host():
assert _canonical_url("HTTPS://API.Example.COM/u/A") == _canonical_url(
"https://api.example.com/u/A"
)
def test_canonical_url_distinguishes_different_paths():
assert _canonical_url("https://x.com/a") != _canonical_url("https://x.com/b")
def test_canonical_url_handles_empty():
assert _canonical_url("") == ("", "", "")
def test_domain_tail_two_labels_or_deep_subdomain():
assert _domain_tail("github.com") == "github.com"
assert _domain_tail("api.github.com") == "github.com"
assert _domain_tail("a.b.c.example.com") == "example.com"
def test_domain_tail_degenerate():
assert _domain_tail("localhost") == "localhost"
assert _domain_tail("") == ""
class _MutationChecker(CheckerMock):
"""Checker that hands out canned responses in order and records URLs seen."""
def __init__(self, responses):
super().__init__()
self._responses = list(responses)
self.urls_seen = []
self.headers = {}
self.timeout = 3
def prepare(self, url, headers=None, allow_redirects=True, timeout=0, method='get', payload=None, encoding=None):
self.urls_seen.append(url)
self.headers = headers or {}
self.timeout = timeout or self.timeout
return None
async def check(self):
if not self._responses:
return '', 0, None
return self._responses.pop(0)
@pytest.mark.asyncio
async def test_run_url_mutations_merges_extracted_fields(monkeypatch):
"""A mutation URL fetch → extract → merge into ids_data / ids_usernames / ids_links."""
from maigret import checking as checking_mod
site = _make_site()
checker = _MutationChecker([("api-body", 200, None)])
result = MaigretCheckResult("alice", "TestSite", "https://x/alice", MaigretCheckStatus.CLAIMED)
result.ids_data = {"bio": "from base page"}
results_info = {
"status": result,
"url_user": "https://x/alice",
"ids_usernames": {"alice_base": "username"},
"ids_links": ["https://base.example/link"],
}
monkeypatch.setattr(
checking_mod,
"mutate_url",
lambda url: [("https://api.example.com/u/alice", {"X-Api": "1"})],
)
monkeypatch.setattr(
checking_mod,
"extract",
lambda html: {
"uid": "42",
"fullname": "Alice",
"bio": "from mutation (should NOT overwrite)",
"linked_username": "alice_alt",
"links": "['https://mut.example/a']",
"website": "https://alice.example",
},
)
await run_url_mutations(
checker=checker,
site=site,
results_info=results_info,
options={"enrich": True},
logger=Mock(),
)
# mutation URL was hit with merged headers
assert checker.urls_seen == ["https://api.example.com/u/alice"]
assert checker.headers.get("X-Api") == "1"
# new fields land in ids_data, but base-page fields survive
assert result.ids_data["uid"] == "42"
assert result.ids_data["fullname"] == "Alice"
assert result.ids_data["bio"] == "from base page"
# ids_usernames / ids_links merge, base values preserved
assert results_info["ids_usernames"] == {
"alice_base": "username",
"alice_alt": "username",
}
assert "https://base.example/link" in results_info["ids_links"]
assert "https://mut.example/a" in results_info["ids_links"]
assert "https://alice.example" in results_info["ids_links"]
@pytest.mark.asyncio
async def test_run_url_mutations_merge_is_silent_by_default(monkeypatch):
"""A successful merge does NOT emit its own enrich line — the new fields
show up in the [+] Site: block's tree once run_url_mutations returns
(it runs BEFORE query_notify.update). Only diagnostics fire, and those
are verbose-only."""
from maigret import checking as checking_mod
checker = _MutationChecker([("body", 200, None)])
result = MaigretCheckResult("a", "TestSite", "https://x/a", MaigretCheckStatus.CLAIMED)
result.ids_data = {}
results_info = {"status": result, "url_user": "https://x/a"}
monkeypatch.setattr(
checking_mod, "mutate_url", lambda url: [("https://api.example/u/a", {})]
)
monkeypatch.setattr(checking_mod, "extract", lambda html: {"uid": "1"})
notify = Mock()
notify.verbose = False
await run_url_mutations(
checker=checker, site=_make_site(), results_info=results_info,
options={"enrich": True}, logger=Mock(), query_notify=notify,
)
# new field landed in ids_data — it will render inside the site tree
assert result.ids_data.get("uid") == "1"
# no per-merge notification line — the tree IS the notification
non_verbose = [
c for c in notify.enrich.call_args_list
if c.kwargs.get("verbose_only") is not True
]
assert non_verbose == []
@pytest.mark.asyncio
async def test_run_url_mutations_marks_no_data_paths_verbose_only(monkeypatch):
"""No-new-data outcomes (collision, no-scheme, empty body) are all verbose-only."""
from maigret import checking as checking_mod
checker = _MutationChecker([
("body-1", 200, None), # extract matches but nothing new
("body-2", 200, None), # extract returns {} — no scheme matched
("", 404, None), # empty body
])
result = MaigretCheckResult("a", "S", "https://x/a", MaigretCheckStatus.CLAIMED)
result.ids_data = {"uid": "already-known"}
results_info = {"status": result, "url_user": "https://x/a"}
monkeypatch.setattr(
checking_mod, "mutate_url",
lambda url: [
("https://a.example/1", {}),
("https://a.example/2", {}),
("https://a.example/3", {}),
],
)
extract_results = iter([{"uid": "already-known"}, {}, {}])
monkeypatch.setattr(checking_mod, "extract", lambda html: next(extract_results))
notify = Mock()
notify.verbose = False
await run_url_mutations(
checker=checker, site=_make_site(), results_info=results_info,
options={"enrich": True}, logger=Mock(), query_notify=notify,
)
# All 3 non-discovery events flagged verbose_only=True so the real
# notifier would swallow them in default mode.
assert notify.enrich.call_count == 3
for c in notify.enrich.call_args_list:
assert c.kwargs.get("verbose_only") is True
msgs = [c.args[0] for c in notify.enrich.call_args_list]
assert any("no new data" in m for m in msgs)
assert any("no socid_extractor scheme matched" in m for m in msgs)
assert any("empty body" in m for m in msgs)
assert not any("merged +0f" in m for m in msgs)
@pytest.mark.asyncio
async def test_run_url_mutations_skips_cross_site(monkeypatch):
"""A mutation whose domain differs from the site's url_main must be dropped."""
from maigret import checking as checking_mod
checker = _MutationChecker([("body", 200, None)])
result = MaigretCheckResult("a", "S", "https://archive.org/x", MaigretCheckStatus.CLAIMED)
result.ids_data = {}
results_info = {
"status": result,
"url_user": "https://archive.org/wayback/available?url=https://twitter.com/a",
}
monkeypatch.setattr(
checking_mod, "mutate_url",
lambda url: [
("https://twitter.com/i/api/graphql/...", set()), # foreign — drop
("https://web.archive.org/wayback/api/...", set()), # same eTLD+1 — keep
],
)
monkeypatch.setattr(checking_mod, "extract", lambda html: {})
notify = Mock()
notify.verbose = False
await run_url_mutations(
checker=checker,
site=_make_site({"urlMain": "https://archive.org"}),
results_info=results_info,
options={"enrich": True},
logger=Mock(),
query_notify=notify,
)
assert checker.urls_seen == ["https://web.archive.org/wayback/api/..."]
skip_calls = [c for c in notify.enrich.call_args_list if "cross-site" in c.args[0]]
assert len(skip_calls) == 1
assert skip_calls[0].kwargs.get("verbose_only") is True
@pytest.mark.asyncio
async def test_run_url_mutations_drops_body_headers(monkeypatch):
"""Content-Type / Content-Length from the base POST must not leak into a GET
mutation — LeetCode GraphQL 400s on a body-less GET with application/json."""
from maigret import checking as checking_mod
checker = _MutationChecker([("body", 200, None)])
# simulate a POST-based base check whose checker.headers carry Content-Type
checker.headers = {"User-Agent": "UA", "Content-Type": "application/json"}
result = MaigretCheckResult("a", "S", "https://x/a", MaigretCheckStatus.CLAIMED)
result.ids_data = {}
results_info = {"status": result, "url_user": "https://x/a"}
monkeypatch.setattr(
checking_mod, "mutate_url", lambda url: [("https://api.example/gql", {})],
)
monkeypatch.setattr(checking_mod, "extract", lambda html: {})
await run_url_mutations(
checker=checker, site=_make_site({"headers": {"Content-Type": "application/json"}}),
results_info=results_info, options={"enrich": True}, logger=Mock(),
)
assert "Content-Type" not in checker.headers
assert "Content-Length" not in checker.headers
assert checker.headers.get("User-Agent") == "UA"
@pytest.mark.asyncio
async def test_run_url_mutations_skips_url_probe_duplicate(monkeypatch):
"""A mutation URL equal to the site's already-fetched url_probe must be skipped."""
from maigret import checking as checking_mod
checker = _MutationChecker([("body", 200, None)])
result = MaigretCheckResult("a", "S", "https://x/a", MaigretCheckStatus.CLAIMED)
result.ids_data = {}
results_info = {
"status": result,
"url_user": "https://x/a",
"url_probe": "https://api.example/u/a",
}
monkeypatch.setattr(
checking_mod, "mutate_url",
lambda url: [
# scheme+host+path match url_probe (differ only in query/trailing slash) — drop
("https://api.example/u/a/?q=1", {}),
("https://other.example/u/a", {}), # keep
],
)
monkeypatch.setattr(checking_mod, "extract", lambda html: {})
notify = Mock()
notify.verbose = False
await run_url_mutations(
checker=checker, site=_make_site(), results_info=results_info,
options={"enrich": True}, logger=Mock(), query_notify=notify,
)
# only the non-duplicate URL was fetched
assert checker.urls_seen == ["https://other.example/u/a"]
# skip message is emitted with the new wording, verbose-only
skip_calls = [c for c in notify.enrich.call_args_list if "skip" in c.args[0]]
assert len(skip_calls) == 1
assert "maigret has built-in check for this mutation" in skip_calls[0].args[0]
assert skip_calls[0].kwargs.get("verbose_only") is True
@pytest.mark.asyncio
async def test_run_url_mutations_empty_mutations_is_noop(monkeypatch):
from maigret import checking as checking_mod
checker = _MutationChecker([])
result = MaigretCheckResult("a", "S", "https://x/a", MaigretCheckStatus.CLAIMED)
results_info = {"status": result, "url_user": "https://x/a"}
monkeypatch.setattr(checking_mod, "mutate_url", lambda url: [])
# extract must never be called
monkeypatch.setattr(
checking_mod, "extract", lambda html: (_ for _ in ()).throw(AssertionError("should not run"))
)
await run_url_mutations(
checker=checker, site=_make_site(), results_info=results_info,
options={"enrich": True}, logger=Mock(),
)
assert checker.urls_seen == []
@pytest.mark.asyncio
async def test_run_url_mutations_caps_at_max(monkeypatch):
from maigret import checking as checking_mod
n = MAX_MUTATIONS_PER_SITE + 5
checker = _MutationChecker([("body", 200, None)] * n)
result = MaigretCheckResult("a", "S", "https://x/a", MaigretCheckStatus.CLAIMED)
result.ids_data = {}
results_info = {"status": result, "url_user": "https://x/a"}
monkeypatch.setattr(
checking_mod, "mutate_url",
lambda url: [(f"https://m{i}.example/a", {}) for i in range(n)],
)
monkeypatch.setattr(checking_mod, "extract", lambda html: {})
options = {"enrich": True, "enrich_requests": 0}
await run_url_mutations(
checker=checker, site=_make_site(), results_info=results_info,
options=options, logger=Mock(),
)
assert len(checker.urls_seen) == MAX_MUTATIONS_PER_SITE
# counter tracks attempted requests
assert options["enrich_requests"] == MAX_MUTATIONS_PER_SITE
@pytest.mark.asyncio
async def test_run_url_mutations_swallows_checker_exception(monkeypatch):
"""A crashing mutation request must not sink the primary CLAIMED result."""
from maigret import checking as checking_mod
class ExplodingChecker(_MutationChecker):
async def check(self):
raise RuntimeError("network is on fire")
checker = ExplodingChecker([])
result = MaigretCheckResult("a", "S", "https://x/a", MaigretCheckStatus.CLAIMED)
result.ids_data = {"bio": "kept"}
results_info = {"status": result, "url_user": "https://x/a"}
monkeypatch.setattr(
checking_mod, "mutate_url",
lambda url: [("https://m.example/a", {})],
)
monkeypatch.setattr(checking_mod, "extract", lambda html: {"uid": "must-not-be-added"})
logger = Mock()
await run_url_mutations(
checker=checker, site=_make_site(), results_info=results_info,
options={"enrich": True}, logger=logger,
)
# primary result intact; nothing partial injected
assert result.ids_data == {"bio": "kept"}
logger.warning.assert_called()
@pytest.mark.asyncio
async def test_enrich_disabled_skips_mutations(monkeypatch):
"""Without options['enrich'], check_site_for_username must not call mutate_url."""
from maigret import checking as checking_mod
called = {"n": 0}
def spy_mutate(url):
called["n"] += 1
return []
monkeypatch.setattr(checking_mod, "mutate_url", spy_mutate)
site = _make_site()
options = {
"parsing": False,
"enrich": False,
"cookie_jar": None,
"forced": False,
"id_type": "username",
"timeout": 3,
"proxy": None,
"checkers": {"": CheckerMock()},
}
await check_site_for_username(site, "a", options, Mock(), Mock())
assert called["n"] == 0
class _RaisingPayloadCM:
"""Async context manager simulating a rotating proxy dropping the
connection mid-body: aiohttp surfaces this as ClientPayloadError."""
async def __aenter__(self):
from aiohttp.client_exceptions import ClientPayloadError
raise ClientPayloadError("Response payload is not completed")
async def __aexit__(self, *exc):
return False
class _OkResponseCM:
status = 200
charset = 'utf-8'
class _Content:
async def read(self):
return b'ok'
content = _Content()
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
@pytest.mark.asyncio
async def test_simple_aiohttp_checker_retries_once_on_payload_error():
from maigret.checking import SimpleAiohttpChecker
calls = []
def fake_get(**kwargs):
calls.append(kwargs)
return _RaisingPayloadCM() if len(calls) == 1 else _OkResponseCM()
session = Mock()
session.get = fake_get
checker = SimpleAiohttpChecker(logger=Mock())
text, status, error = await checker._make_request(
session, 'http://example.com', {}, True, 5, 'get', Mock()
)
assert len(calls) == 2
assert error is None
assert status == 200
assert text == 'ok'
@pytest.mark.asyncio
async def test_simple_aiohttp_checker_gives_up_as_payload_error_after_retry():
from maigret.checking import SimpleAiohttpChecker
calls = []
def fake_get(**kwargs):
calls.append(kwargs)
return _RaisingPayloadCM()
session = Mock()
session.get = fake_get
checker = SimpleAiohttpChecker(logger=Mock())
text, status, error = await checker._make_request(
session, 'http://example.com', {}, True, 5, 'get', Mock()
)
assert len(calls) == 2 # initial attempt + one retry, then gives up
assert error.type == 'Payload'
class _RaisingCM:
"""Async context manager that raises the given exception on entry."""
def __init__(self, exc):
self._exc = exc
async def __aenter__(self):
raise self._exc
async def __aexit__(self, *exc):
return False
def _fake_get_raising_then_ok(exc_factory):
calls = []
def fake_get(**kwargs):
calls.append(kwargs)
return _RaisingCM(exc_factory()) if len(calls) == 1 else _OkResponseCM()
return calls, fake_get
def _fake_get_always_raising(exc_factory):
calls = []
def fake_get(**kwargs):
calls.append(kwargs)
return _RaisingCM(exc_factory())
return calls, fake_get
@pytest.mark.asyncio
async def test_simple_aiohttp_checker_retries_server_disconnected():
from maigret.checking import SimpleAiohttpChecker
calls, fake_get = _fake_get_raising_then_ok(
lambda: ServerDisconnectedError("Server disconnected")
)
session = Mock()
session.get = fake_get
checker = SimpleAiohttpChecker(logger=Mock())
text, status, error = await checker._make_request(
session, 'http://example.com', {}, True, 5, 'get', Mock()
)
assert len(calls) == 2
assert error is None
assert status == 200
@pytest.mark.asyncio
async def test_simple_aiohttp_checker_gives_up_as_server_disconnected_after_retry():
from maigret.checking import SimpleAiohttpChecker
calls, fake_get = _fake_get_always_raising(
lambda: ServerDisconnectedError("Server disconnected")
)
session = Mock()
session.get = fake_get
checker = SimpleAiohttpChecker(logger=Mock())
text, status, error = await checker._make_request(
session, 'http://example.com', {}, True, 5, 'get', Mock()
)
assert len(calls) == 2
assert error.type == 'Server disconnected'
@pytest.mark.asyncio
async def test_simple_aiohttp_checker_retries_proxy_connection_error():
from maigret.checking import SimpleAiohttpChecker, ProxyConnectionError
calls, fake_get = _fake_get_raising_then_ok(
lambda: ProxyConnectionError("Couldn't connect to proxy")
)
session = Mock()
session.get = fake_get
checker = SimpleAiohttpChecker(logger=Mock())
text, status, error = await checker._make_request(
session, 'http://example.com', {}, True, 5, 'get', Mock()
)
assert len(calls) == 2
assert error is None
assert status == 200
@pytest.mark.asyncio
async def test_simple_aiohttp_checker_gives_up_as_proxy_after_retry_on_proxy_timeout():
from maigret.checking import SimpleAiohttpChecker, ProxyTimeoutError
calls, fake_get = _fake_get_always_raising(
lambda: ProxyTimeoutError("Proxy connection timed out")
)
session = Mock()
session.get = fake_get
checker = SimpleAiohttpChecker(logger=Mock())
text, status, error = await checker._make_request(
session, 'http://example.com', {}, True, 5, 'get', Mock()
)
assert len(calls) == 2
assert error.type == 'Proxy'
@pytest.mark.asyncio
async def test_simple_aiohttp_checker_does_not_retry_generic_proxy_error():
"""Unlike ProxyConnectionError/ProxyTimeoutError, a generic ProxyError
(e.g. bad credentials) fails identically every time — retrying it would
double the cost of every check for zero chance of success."""
from maigret.checking import SimpleAiohttpChecker, ProxyError
calls, fake_get = _fake_get_always_raising(
lambda: ProxyError("Unsupported proxy response")
)
session = Mock()
session.get = fake_get
checker = SimpleAiohttpChecker(logger=Mock())
text, status, error = await checker._make_request(
session, 'http://example.com', {}, True, 5, 'get', Mock()
)
assert len(calls) == 1 # no retry
assert error.type == 'Proxy'
# --- SOCKS proxy scheme normalization tests (issue #2955) ---
@pytest.mark.parametrize(
'given, expected',
[
# socks5h is rejected outright by python_socks, so it must be rewritten
('socks5h://127.0.0.1:1080', 'socks5://127.0.0.1:1080'),
# socks5 already means proxy-side DNS there: pass through untouched
('socks5://127.0.0.1:1080', 'socks5://127.0.0.1:1080'),
# schemes python_socks handles natively must not be touched
('http://127.0.0.1:8080', 'http://127.0.0.1:8080'),
('socks4://127.0.0.1:1080', 'socks4://127.0.0.1:1080'),
],
)
def test_aiohttp_checker_normalizes_proxy_scheme(given, expected):
from maigret.checking import SimpleAiohttpChecker
assert SimpleAiohttpChecker(logger=Mock(), proxy=given).proxy == expected
@pytest.mark.parametrize(
'given, expected',
[
# libcurl resolves locally for socks5, leaking hostnames past the proxy
('socks5://127.0.0.1:1080', 'socks5h://127.0.0.1:1080'),
('socks5h://127.0.0.1:1080', 'socks5h://127.0.0.1:1080'),
('http://127.0.0.1:8080', 'http://127.0.0.1:8080'),
('socks4://127.0.0.1:1080', 'socks4://127.0.0.1:1080'),
],
)
def test_curl_cffi_checker_normalizes_proxy_scheme(given, expected):
from maigret.checking import CurlCffiChecker
assert CurlCffiChecker(logger=Mock(), proxy=given).proxy == expected
def test_proxied_aiohttp_checker_normalizes_proxy_scheme():
"""--tor-proxy / --i2p-proxy go through the subclass, and .onion / .i2p
names only resolve at the proxy, so the same normalization must apply."""
from maigret.checking import ProxiedAiohttpChecker
checker = ProxiedAiohttpChecker(logger=Mock(), proxy='socks5h://127.0.0.1:9050')
assert checker.proxy == 'socks5://127.0.0.1:9050'
def test_both_checkers_agree_on_proxy_side_dns():
"""Whichever spelling the user passes, both transports end up resolving
at the proxy."""
from maigret.checking import SimpleAiohttpChecker, CurlCffiChecker
for spelling in ('socks5://127.0.0.1:1080', 'socks5h://127.0.0.1:1080'):
# socks5 + python_socks rdns default of True == socks5h + libcurl
assert SimpleAiohttpChecker(logger=Mock(), proxy=spelling).proxy == (
'socks5://127.0.0.1:1080'
)
assert CurlCffiChecker(logger=Mock(), proxy=spelling).proxy == (
'socks5h://127.0.0.1:1080'
)
@pytest.mark.parametrize(
'transport, given, expected',
[
# credentials, IPv6 literals and paths must survive untouched
(
'python_socks',
'socks5h://user:p%40ss@proxy.example:1080',
'socks5://user:p%40ss@proxy.example:1080',
),
(
'libcurl',
'socks5://user:p%40ss@[::1]:1080',
'socks5h://user:p%40ss@[::1]:1080',
),
(
'requests',
'socks5://user:p%40ss@[::1]:1080',
'socks5h://user:p%40ss@[::1]:1080',
),
# the scheme match is case-insensitive, like every other URL scheme
('python_socks', 'SOCKS5H://127.0.0.1:1080', 'socks5://127.0.0.1:1080'),
('libcurl', 'Socks5://127.0.0.1:1080', 'socks5h://127.0.0.1:1080'),
# only the leading scheme is rewritten, never a match inside the URL
(
'libcurl',
'http://user:socks5://@127.0.0.1:8080',
'http://user:socks5://@127.0.0.1:8080',
),
# no proxy configured, or a value with no scheme at all
('python_socks', None, None),
('libcurl', '', ''),
('libcurl', '127.0.0.1:1080', '127.0.0.1:1080'),
],
)
def test_normalize_proxy_scheme(transport, given, expected):
from maigret.checking import normalize_proxy_scheme
assert normalize_proxy_scheme(given, transport) == expected
class _ProbeRecordingChecker:
"""Records the URL it was asked to fetch.
Deliberately not a CheckerMock subclass: make_site_result treats a
CheckerMock as "no gateway configured" and marks the site illegal
before it ever builds the probe URL.
"""
def __init__(self):
self.urls_seen = []
def prepare(self, url, **kwargs):
self.urls_seen.append(url)
return None
async def check(self):
return '', 200, None
async def close(self):
return
def _url_probe_site():
return MaigretSite(
"ProbeAPI",
{
"checkType": "status_code",
"urlMain": "http://localhost:8989/",
"url": "http://localhost:8989/{username}",
"urlProbe": "http://localhost:8989/api/users/{username}",
"usernameClaimed": "user",
"usernameUnclaimed": "nosuchuser",
},
)
@pytest.mark.parametrize(
"username, expected_probe",
[
# a plain username must come out byte-identical to before
("user", "http://localhost:8989/api/users/user"),
# "#" used to end the URL and turn the rest into a fragment
("user#1234", "http://localhost:8989/api/users/user%231234"),
# "?" used to start a query string
("user?x=1", "http://localhost:8989/api/users/user%3Fx%3D1"),
],
)
def test_url_probe_username_is_percent_encoded(username, expected_probe):
"""urlProbe has to encode the username the same way the display URL does."""
checker = _ProbeRecordingChecker()
options = {
"id_type": "username",
"parsing": False,
"timeout": 1,
"forced": False,
"checkers": {"": lambda: checker},
}
results = make_site_result(_url_probe_site(), username, options, Mock())
assert results["url_probe"] == expected_probe
# and that is really the URL handed to the checker, not just a report field
assert checker.urls_seen == [expected_probe]
@pytest.mark.slow
@pytest.mark.asyncio
async def test_url_probe_fragment_does_not_probe_another_account(httpserver):
"""A "#" in the username must not make the probe land on another account."""
# only the real account "user" exists on this API, everything else is a 404
httpserver.expect_request("/api/users/user").respond_with_data("{}", status=200)
httpserver.expect_request(re.compile("^/api/users/")).respond_with_data(
"{}", status=404
)
results = await search(
"user#1234",
site_dict={"ProbeAPI": _url_probe_site()},
logger=Mock(),
timeout=5,
no_progressbar=True,
)
requested = [request.path for request, _ in httpserver.log]
# the request really happened, so this cannot pass just because the
# server was never reached
assert len(requested) == 1
# the whole username stayed in the path segment; before the fix the server
# saw "/api/users/user" and answered 200 for somebody else's account
assert requested == ["/api/users/user#1234"]
assert results["ProbeAPI"]["status"].is_found() is False
@pytest.mark.asyncio
async def test_self_check_forwards_cookies_jar_file():
"""--cookies-jar-file must reach the site checks, as it does for --submit."""
site = MaigretSite('Test', {'tags': [], 'disabled': False})
with patch(
'maigret.checking.site_self_check', new_callable=AsyncMock
) as mock_site_self_check:
mock_site_self_check.return_value = {
'disabled': False,
'issues': [],
'recommendations': [],
}
await self_check(
Mock(),
{'Test': site},
Mock(),
silent=True,
no_progressbar=True,
cookies='my_cookies.txt',
)
mock_site_self_check.assert_awaited_once()
assert mock_site_self_check.call_args.kwargs.get('cookies') == 'my_cookies.txt'
@pytest.mark.asyncio
async def test_self_check_without_cookies_jar_file():
site = MaigretSite('Test', {'tags': [], 'disabled': False})
with patch(
'maigret.checking.site_self_check', new_callable=AsyncMock
) as mock_site_self_check:
mock_site_self_check.return_value = {
'disabled': False,
'issues': [],
'recommendations': [],
}
await self_check(
Mock(), {'Test': site}, Mock(), silent=True, no_progressbar=True
)
assert mock_site_self_check.call_args.kwargs.get('cookies') is None