"""Maigret activation test functions""" import inspect import json import os import yarl import aiohttp import pytest from unittest.mock import Mock from tests.conftest import LOCAL_SERVER_PORT from maigret import activation as activation_cache from maigret.activation import ( ParsingActivator, import_aiohttp_cookies, load_activation_cache, save_activation_cache, ) from maigret.sites import MaigretDatabase, MaigretSite COOKIES_TXT = """# HTTP Cookie File downloaded with cookies.txt by Genuinous @genuinous # This file can be used by wget, curl, aria2c and other standard compliant tools. # Usage Examples: # 1) wget -x --load-cookies cookies.txt "https://xss.is/search/" # 2) curl --cookie cookies.txt "https://xss.is/search/" # 3) aria2c --load-cookies cookies.txt "https://xss.is/search/" # xss.is FALSE / TRUE 0 xf_csrf test xss.is FALSE / TRUE 1642709308 xf_user tset .xss.is TRUE / FALSE 0 muchacho_cache test .xss.is TRUE / FALSE 1924905600 132_evc test localhost FALSE / FALSE 0 a b """ @pytest.mark.skip("captcha") @pytest.mark.slow @pytest.mark.asyncio async def test_vimeo_activation(default_db): vimeo_site = default_db.sites_dict['Vimeo'] token1 = vimeo_site.headers['Authorization'] await ParsingActivator.vimeo(vimeo_site, Mock()) token2 = vimeo_site.headers['Authorization'] assert token1 != token2 @pytest.mark.slow @pytest.mark.asyncio async def test_import_aiohttp_cookies(cookie_test_server): cookies_filename = 'cookies_test.txt' with open(cookies_filename, 'w') as f: f.write(COOKIES_TXT) cookie_jar = import_aiohttp_cookies(cookies_filename) url = f'http://localhost:{LOCAL_SERVER_PORT}/cookies' cookies = cookie_jar.filter_cookies(yarl.URL(url)) assert cookies['a'].value == 'b' async with aiohttp.ClientSession(cookie_jar=cookie_jar) as session: async with session.get(url=url) as response: result = await response.json() print(f"Server response: {result}") assert result == {'cookies': {'a': 'b'}} # A real browser export usually stores several cookies per domain under # different paths. COOKIES_TXT above happens to keep everything on "/", which # is why the path handling below went untested for so long. MULTIPATH_COOKIES_TXT = """# Netscape HTTP Cookie File .example.com TRUE / FALSE 2147483647 sessionid SESSION .example.com TRUE /account FALSE 2147483647 csrftoken CSRF .example.com TRUE /api FALSE 2147483647 authtoken AUTH """ # A cookie stored with no value at all: the name column is empty and the value # column carries the name, which http.cookiejar parses as value None. VALUELESS_COOKIE_TXT = """# Netscape HTTP Cookie File .example.com TRUE / FALSE 2147483647 consent """ def _write(tmp_path, content): cookies_file = tmp_path / "cookies.txt" cookies_file.write_text(content, encoding="utf-8") return str(cookies_file) @pytest.mark.asyncio async def test_import_aiohttp_cookies_keeps_every_path_of_a_domain(tmp_path): """All of a domain's cookies must survive the import, not just one path. MozillaCookieJar stores cookies as {domain: {path: {name: cookie}}}. Taking a single path bucket per domain silently discarded every cookie saved under the domain's other paths, so an authenticated scan went out missing its CSRF and auth tokens and simply looked logged out. """ cookie_jar = import_aiohttp_cookies(_write(tmp_path, MULTIPATH_COOKIES_TXT)) imported = {morsel.key for morsel in cookie_jar} assert imported == {"sessionid", "csrftoken", "authtoken"} @pytest.mark.asyncio async def test_import_aiohttp_cookies_offers_each_cookie_on_its_own_path(tmp_path): """Path scoping still has to hold once every cookie is imported.""" cookie_jar = import_aiohttp_cookies(_write(tmp_path, MULTIPATH_COOKIES_TXT)) def sent_to(url): return { key: morsel.value for key, morsel in cookie_jar.filter_cookies(yarl.URL(url)).items() } assert sent_to("http://www.example.com/") == {"sessionid": "SESSION"} assert sent_to("http://www.example.com/account/profile") == { "sessionid": "SESSION", "csrftoken": "CSRF", } assert sent_to("http://www.example.com/api/v1/users") == { "sessionid": "SESSION", "authtoken": "AUTH", } @pytest.mark.asyncio async def test_import_aiohttp_cookies_does_not_invent_a_none_value(tmp_path): """A valueless cookie must not reach the wire as the literal text 'None'.""" cookie_jar = import_aiohttp_cookies(_write(tmp_path, VALUELESS_COOKIE_TXT)) sent = cookie_jar.filter_cookies(yarl.URL("http://www.example.com/")) assert sent["consent"].value == "" # ---- OnlyFans signing tests (pure-compute, no network) ---- class _FakeSite: """Minimal stand-in for MaigretSite with the attributes onlyfans() touches.""" def __init__(self, headers=None, activation=None): self.headers = headers or {} self.activation = activation or { "static_param": "jLM8LXHU1CGcuCzPMNwWX9osCScVuP4D", "checksum_indexes": [28, 3, 16, 32, 25, 24, 23, 0, 26], "checksum_constant": -180, "format": "57203:{}:{:x}:69cfa6d8", "url": "https://onlyfans.com/api2/v2/init", } class _FakeResponse: def __init__(self, cookies=None, json_data=None): self.cookies = { key: type("Cookie", (), {"value": value})() for key, value in (cookies or {}).items() } self._json_data = json_data or {} async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): return False async def json(self, content_type=None): return self._json_data @pytest.mark.parametrize("method", ["twitter", "vimeo", "onlyfans", "weibo", "proton"]) def test_activation_methods_are_coroutines(method): assert inspect.iscoroutinefunction(getattr(ParsingActivator, method)) @pytest.mark.asyncio async def test_vimeo_activation_uses_aiohttp(monkeypatch): site = _FakeSite( headers={"Authorization": "old-token", "User-Agent": "test"}, activation={"url": "https://vimeo.test/viewer"}, ) captured = {} class FakeSession: def __init__(self, **kwargs): captured["session_kwargs"] = kwargs async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): return False def get(self, url, headers=None, timeout=None): captured["url"] = url captured["headers"] = dict(headers or {}) captured["timeout"] = timeout return _FakeResponse(json_data={"jwt": "fresh"}) monkeypatch.setattr("maigret.activation.ClientSession", FakeSession) await ParsingActivator.vimeo(site, Mock(), timeout=7) assert captured["url"] == "https://vimeo.test/viewer" assert captured["headers"] == {"User-Agent": "test"} assert captured["timeout"] == 7 assert captured["session_kwargs"] == {"trust_env": True} assert site.headers["Authorization"] == "jwt fresh" @pytest.mark.asyncio async def test_onlyfans_sets_xbc_when_zero(monkeypatch): site = _FakeSite(headers={"x-bc": "0", "cookie": "existing=1"}) # Prevent any real network. If _sign path still opens a session, fail loudly. def boom(*a, **kw): # pragma: no cover - sanity raise AssertionError("ClientSession should not open when cookie is present") monkeypatch.setattr("maigret.activation.ClientSession", boom) logger = Mock() await ParsingActivator.onlyfans( site, logger, url="https://onlyfans.com/api2/v2/users/adam", ) # x-bc must be rewritten to a non-zero hex token assert site.headers["x-bc"] != "0" assert len(site.headers["x-bc"]) == 40 # 20 bytes → 40 hex chars # time / sign headers set for target URL assert "time" in site.headers and site.headers["time"].isdigit() assert site.headers["sign"].startswith("57203:") @pytest.mark.asyncio async def test_onlyfans_fetches_init_cookie_when_missing(monkeypatch): """When cookie header is absent, init endpoint is called and its cookies stored.""" site = _FakeSite(headers={"x-bc": "already_set_token", "user-id": "0"}) captured = {} class FakeSession: def __init__(self, **kwargs): captured["session_kwargs"] = kwargs async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): return False def get(self, url, headers=None, timeout=15): captured["url"] = url captured["headers"] = dict(headers or {}) captured["timeout"] = timeout return _FakeResponse(cookies={"sess": "abc123", "csrf": "xyz"}) monkeypatch.setattr("maigret.activation.ClientSession", FakeSession) logger = Mock() await ParsingActivator.onlyfans( site, logger, url="https://onlyfans.com/api2/v2/users/adam", ) # init request made assert captured["url"] == site.activation["url"] assert captured["timeout"] == 15 assert captured["session_kwargs"] == {"trust_env": True} # headers passed to init include freshly generated time/sign assert "time" in captured["headers"] assert captured["headers"]["sign"].startswith("57203:") # cookie header populated from response assert site.headers["cookie"] == "sess=abc123; csrf=xyz" @pytest.mark.asyncio async def test_onlyfans_signature_is_deterministic_for_same_time(monkeypatch): """Two calls with patched time produce identical signatures.""" site1 = _FakeSite(headers={"x-bc": "token", "cookie": "c=1"}) site2 = _FakeSite(headers={"x-bc": "token", "cookie": "c=1"}) import maigret.activation monkeypatch.setattr(maigret.activation, "_time", __import__("time"), raising=False) fixed = 1_700_000_000.123 import time as time_mod monkeypatch.setattr(time_mod, "time", lambda: fixed) logger = Mock() await ParsingActivator.onlyfans( site1, logger, url="https://onlyfans.com/api2/v2/users/adam", ) await ParsingActivator.onlyfans( site2, logger, url="https://onlyfans.com/api2/v2/users/adam", ) assert site1.headers["time"] == site2.headers["time"] assert site1.headers["sign"] == site2.headers["sign"] @pytest.mark.asyncio async def test_onlyfans_sign_differs_per_path(monkeypatch): """Different target URLs must yield different signatures.""" site = _FakeSite(headers={"x-bc": "token", "cookie": "c=1"}) import time as time_mod monkeypatch.setattr(time_mod, "time", lambda: 1_700_000_000.0) logger = Mock() await ParsingActivator.onlyfans( site, logger, url="https://onlyfans.com/api2/v2/users/adam", ) sig_adam = site.headers["sign"] await ParsingActivator.onlyfans( site, logger, url="https://onlyfans.com/api2/v2/users/bob", ) sig_bob = site.headers["sign"] assert sig_adam != sig_bob @pytest.mark.asyncio async def test_proton_activation_sets_uid_and_bearer(monkeypatch): """Proton activator bootstraps an anon session and injects UID + Bearer token.""" site = _FakeSite( headers={"X-Pm-Appversion": "web-account@5.0.398.1"}, activation={"url": "https://account.proton.me/api/auth/v4/sessions"}, ) captured = {} class FakeSession: def __init__(self, **kwargs): captured["session_kwargs"] = kwargs async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): return False def post(self, url, headers=None, json=None, timeout=None): captured["url"] = url captured["headers"] = dict(headers or {}) captured["json"] = json captured["timeout"] = timeout return _FakeResponse(json_data={"UID": "uid123", "AccessToken": "tok456"}) monkeypatch.setattr("maigret.activation.ClientSession", FakeSession) await ParsingActivator.proton(site, Mock(), timeout=7) assert captured["url"] == "https://account.proton.me/api/auth/v4/sessions" assert captured["headers"] == {"X-Pm-Appversion": "web-account@5.0.398.1"} assert captured["json"] == {} assert captured["timeout"] == 7 assert site.headers["x-pm-uid"] == "uid123" assert site.headers["Authorization"] == "Bearer tok456" @pytest.mark.asyncio async def test_proton_activation_strips_stale_auth_from_bootstrap(monkeypatch): """A prior Authorization/x-pm-uid must not be sent to the session endpoint.""" site = _FakeSite( headers={ "X-Pm-Appversion": "web-account@5.0.398.1", "Authorization": "Bearer stale", "x-pm-uid": "stale-uid", }, activation={"url": "https://account.proton.me/api/auth/v4/sessions"}, ) captured = {} class FakeSession: def __init__(self, **kwargs): pass async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): return False def post(self, url, headers=None, json=None, timeout=None): captured["headers"] = dict(headers or {}) return _FakeResponse(json_data={"UID": "u", "AccessToken": "t"}) monkeypatch.setattr("maigret.activation.ClientSession", FakeSession) await ParsingActivator.proton(site, Mock()) assert "Authorization" not in captured["headers"] assert "x-pm-uid" not in captured["headers"] assert site.headers["Authorization"] == "Bearer t" assert site.headers["x-pm-uid"] == "u" @pytest.mark.asyncio async def test_proton_activation_missing_token_leaves_headers_untouched(monkeypatch): """If Proton returns an error payload, do not inject broken auth headers.""" site = _FakeSite( headers={"X-Pm-Appversion": "web-account@5.0.398.1"}, activation={"url": "https://account.proton.me/api/auth/v4/sessions"}, ) class FakeSession: def __init__(self, **kwargs): pass async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): return False def post(self, url, headers=None, json=None, timeout=None): return _FakeResponse(json_data={"Code": 5002, "Error": "Missing header"}) monkeypatch.setattr("maigret.activation.ClientSession", FakeSession) logger = Mock() await ParsingActivator.proton(site, logger) assert "Authorization" not in site.headers assert "x-pm-uid" not in site.headers logger.warning.assert_called_once() @pytest.mark.asyncio async def test_wikimapia_activation_parses_token_from_challenge(): """The Wikimapia activator reads the ngxsession token from the challenge body the checker already fetched and merges it into the request cookie.""" site = Mock() site.name = "WikimapiaSearch" site.headers = {"Cookie": "verified=1"} challenge = ( '' '' '' ) await ParsingActivator.wikimapia(site, Mock(), html=challenge) assert site.headers["Cookie"] == "verified=1; ngxsession=deadbeef0123456789" @pytest.mark.asyncio async def test_wikimapia_activation_replaces_stale_token(): """A previously merged ngxsession is replaced, not duplicated, on re-activation.""" site = Mock() site.name = "WikimapiaSearch" site.headers = {"Cookie": "verified=1; ngxsession=0000000000000000"} challenge = '' await ParsingActivator.wikimapia(site, Mock(), html=challenge) assert site.headers["Cookie"] == "verified=1; ngxsession=abcdef0123456789" @pytest.mark.asyncio async def test_wikimapia_activation_no_token_leaves_cookie_untouched(): """If the body carries no token, the cookie header is left as-is.""" site = Mock() site.name = "WikimapiaSearch" site.headers = {"Cookie": "verified=1"} await ParsingActivator.wikimapia(site, Mock(), html="no challenge here") assert site.headers["Cookie"] == "verified=1" @pytest.fixture def activation_db(monkeypatch, tmp_path): """Two sites, one that mints tokens at runtime and one that doesn't.""" db = MaigretDatabase() db.update_site( MaigretSite( 'Twitter', { 'url': 'https://twitter.com/{username}', 'urlMain': 'https://twitter.com/', 'activation': {'method': 'twitter', 'marks': ['nope']}, 'headers': {'x-guest-token': 'from-db', 'accept': 'text/html'}, }, ) ) db.update_site( MaigretSite( 'Plain', { 'url': 'https://example.com/{username}', 'urlMain': 'https://example.com/', 'headers': {'accept': 'text/html'}, }, ) ) monkeypatch.setattr(activation_cache, 'MAIGRET_HOME', str(tmp_path)) monkeypatch.setattr( activation_cache, 'ACTIVATION_CACHE_PATH', str(tmp_path / 'activation.json') ) return db def test_activation_cache_persists_only_minted_headers(activation_db): """The database ships baseline headers; only what the run minted is cached. Storing the full header set would pin whatever the database shipped, so a later database update could never change those headers again. """ logger = Mock() baseline = load_activation_cache(activation_db, logger) # simulate what ParsingActivator does mid-run activation_db.sites_dict['Twitter'].headers['x-guest-token'] = 'minted' save_activation_cache(activation_db, baseline, logger) written = json.loads(open(activation_cache.ACTIVATION_CACHE_PATH).read()) assert written == {'Twitter': {'x-guest-token': 'minted'}} # the unchanged baseline header is not copied into the cache assert 'accept' not in written['Twitter'] def test_activation_cache_round_trip_applies_token(activation_db): logger = Mock() baseline = load_activation_cache(activation_db, logger) activation_db.sites_dict['Twitter'].headers['x-guest-token'] = 'minted' save_activation_cache(activation_db, baseline, logger) # a fresh run loads the database again, with the shipped value activation_db.sites_dict['Twitter'].headers['x-guest-token'] = 'from-db' load_activation_cache(activation_db, logger) assert activation_db.sites_dict['Twitter'].headers['x-guest-token'] == 'minted' def test_activation_cache_skips_sites_without_activation(activation_db): logger = Mock() baseline = load_activation_cache(activation_db, logger) activation_db.sites_dict['Plain'].headers['accept'] = 'application/json' save_activation_cache(activation_db, baseline, logger) assert not os.path.exists(activation_cache.ACTIVATION_CACHE_PATH) def test_activation_cache_ignores_corrupt_file(activation_db): logger = Mock() with open(activation_cache.ACTIVATION_CACHE_PATH, 'w') as f: f.write('{ this is not json') baseline = load_activation_cache(activation_db, logger) assert baseline['Twitter']['x-guest-token'] == 'from-db' assert activation_db.sites_dict['Twitter'].headers['x-guest-token'] == 'from-db' assert logger.debug.called def test_activation_cache_is_not_world_readable(activation_db): """The cache holds session credentials, so the umask must not decide.""" logger = Mock() baseline = load_activation_cache(activation_db, logger) activation_db.sites_dict['Twitter'].headers['x-guest-token'] = 'minted' save_activation_cache(activation_db, baseline, logger) mode = os.stat(activation_cache.ACTIVATION_CACHE_PATH).st_mode & 0o777 assert mode == 0o600, f"expected 0600, got {mode:o}" def test_activation_cache_does_not_leak_into_shared_headers(monkeypatch, tmp_path): """MaigretSite.headers defaults to a mutable class attribute. A site with an activation block but no headers of its own shares that object with ~3000 other sites, so an in-place update would attach the cached token to every one of them. """ logger = Mock() db = MaigretDatabase() db.update_site( MaigretSite( 'NoHeaders', { 'url': 'https://a.example/{username}', 'urlMain': 'https://a.example/', 'activation': {'method': 'twitter', 'marks': ['nope']}, }, ) ) db.update_site( MaigretSite( 'Bystander', {'url': 'https://b.example/{username}', 'urlMain': 'https://b.example/'}, ) ) cache_file = tmp_path / 'activation.json' cache_file.write_text(json.dumps({'NoHeaders': {'x-guest-token': 'secret'}})) monkeypatch.setattr(activation_cache, 'MAIGRET_HOME', str(tmp_path)) monkeypatch.setattr(activation_cache, 'ACTIVATION_CACHE_PATH', str(cache_file)) load_activation_cache(db, logger) assert db.sites_dict['NoHeaders'].headers['x-guest-token'] == 'secret' assert 'x-guest-token' not in db.sites_dict['Bystander'].headers assert 'x-guest-token' not in MaigretSite.headers