Open-source Grok free registration CLI, xai_enroller auth pipeline, local auth service, tests and docs.
1326 lines
47 KiB
Python
1326 lines
47 KiB
Python
import asyncio
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
import types
|
|
import unittest
|
|
|
|
from grok_register.core.observer import Metrics
|
|
|
|
|
|
playwright_pkg = types.ModuleType("playwright")
|
|
playwright_async_api = types.ModuleType("playwright.async_api")
|
|
playwright_async_api.async_playwright = lambda: None
|
|
sys.modules.setdefault("playwright", playwright_pkg)
|
|
sys.modules.setdefault("playwright.async_api", playwright_async_api)
|
|
|
|
requests_mod = types.ModuleType("requests")
|
|
requests_mod.get = lambda *_args, **_kwargs: None
|
|
requests_mod.post = lambda *_args, **_kwargs: None
|
|
sys.modules.setdefault("requests", requests_mod)
|
|
|
|
from grok_register import register
|
|
|
|
|
|
def test_registration_persists_exact_session_before_legacy_outputs(monkeypatch):
|
|
writes = []
|
|
monkeypatch.setattr(
|
|
register,
|
|
"_append_registration_line",
|
|
lambda path, line, mode=None, durable=False: writes.append(
|
|
(path, line, mode, durable)
|
|
),
|
|
)
|
|
|
|
register._persist_registration(
|
|
"user@example.test",
|
|
"password",
|
|
"sso-token",
|
|
[{"name": "sso", "value": "opaque", "domain": "accounts.x.ai"}],
|
|
)
|
|
|
|
assert [item[0] for item in writes] == [
|
|
"keys/auth-sessions.jsonl",
|
|
"keys/accounts.txt",
|
|
"keys/grok.txt",
|
|
]
|
|
assert writes[0][2] == 0o600
|
|
assert [item[3] for item in writes] == [True, False, False]
|
|
|
|
|
|
class FakePage:
|
|
def __init__(self, context=None):
|
|
self.context = context
|
|
self.closed = False
|
|
self.url = "about:blank"
|
|
self.goto_calls = []
|
|
self.waits = []
|
|
self.evaluations = []
|
|
self.route_calls = []
|
|
self.mouse = types.SimpleNamespace(
|
|
moves=[],
|
|
clicks=[],
|
|
downs=0,
|
|
ups=0,
|
|
move=self._mouse_move,
|
|
click=self._mouse_click,
|
|
down=self._mouse_down,
|
|
up=self._mouse_up,
|
|
)
|
|
self.turnstile_token = ""
|
|
self.turnstile_box = {"x": 160, "y": 45}
|
|
self.turnstile_page_trace = {
|
|
"created_at": 10.0,
|
|
"script_inserted_at": 11.0,
|
|
"script_loaded_at": 12.0,
|
|
"render_called_at": 13.0,
|
|
"render_returned_at": 14.0,
|
|
"token_written_at": None,
|
|
"token_len": 0,
|
|
"error": None,
|
|
}
|
|
self.turnstile_dom_snapshot = {
|
|
"widget": {"present": True, "x": 10, "y": 10, "w": 300, "h": 70, "visible": True},
|
|
"click_center": {"x": 160, "y": 45},
|
|
"element_at_center": {"tag": "IFRAME", "id": "", "class": "", "is_iframe": True},
|
|
"all_iframe_count": 1,
|
|
"turnstile_iframe_count": 1,
|
|
"iframe_summaries": [
|
|
{"host": "challenges.cloudflare.com", "path": "/turnstile/v0", "x": 10, "y": 10, "w": 300, "h": 70, "visible": True}
|
|
],
|
|
"turnstile_loaded": True,
|
|
"response_input": {"present": True, "token_len": 0},
|
|
}
|
|
|
|
async def set_viewport_size(self, size):
|
|
self.viewport = size
|
|
pass
|
|
|
|
async def goto(self, url, timeout=None, wait_until=None):
|
|
self.url = url
|
|
self.goto_calls.append({
|
|
"url": url,
|
|
"timeout": timeout,
|
|
"wait_until": wait_until,
|
|
})
|
|
pass
|
|
|
|
async def wait_for_timeout(self, timeout):
|
|
self.waits.append(timeout)
|
|
pass
|
|
|
|
async def evaluate(self, script):
|
|
self.evaluations.append(script)
|
|
if "__csp_solver_snapshot" in script:
|
|
return self.turnstile_dom_snapshot
|
|
if "__cspTurnstileTrace" in script and "return window.__cspTurnstileTrace" in script:
|
|
return self.turnstile_page_trace
|
|
if "cf-turnstile-response" in script:
|
|
return self.turnstile_token
|
|
if "getBoundingClientRect" in script and ".cf-turnstile" in script:
|
|
return self.turnstile_box
|
|
return None
|
|
|
|
async def close(self):
|
|
self.closed = True
|
|
pass
|
|
|
|
async def route(self, pattern, handler):
|
|
self.route_calls.append({"pattern": pattern, "handler": handler})
|
|
|
|
async def _mouse_move(self, x, y, steps=None):
|
|
self.mouse.moves.append({"x": x, "y": y, "steps": steps})
|
|
|
|
async def _mouse_click(self, x, y):
|
|
self.mouse.clicks.append({"x": x, "y": y})
|
|
|
|
async def _mouse_down(self):
|
|
self.mouse.downs += 1
|
|
|
|
async def _mouse_up(self):
|
|
self.mouse.ups += 1
|
|
|
|
|
|
class FakeContext:
|
|
def __init__(self):
|
|
self.pages = []
|
|
self.closed = False
|
|
self.clear_cookies_calls = 0
|
|
self.request = types.SimpleNamespace(get=self._request_get)
|
|
self.request_get_calls = []
|
|
self.cookies_value = []
|
|
self.cancel_on_clear = False
|
|
|
|
async def new_page(self):
|
|
page = FakePage(self)
|
|
self.pages.append(page)
|
|
return page
|
|
|
|
async def clear_cookies(self):
|
|
self.clear_cookies_calls += 1
|
|
if self.cancel_on_clear:
|
|
raise asyncio.CancelledError()
|
|
self.cookies_value = []
|
|
pass
|
|
|
|
async def cookies(self):
|
|
return list(self.cookies_value)
|
|
|
|
async def _request_get(self, url, timeout=None):
|
|
self.request_get_calls.append({"url": url, "timeout": timeout})
|
|
return types.SimpleNamespace(status=403)
|
|
|
|
async def close(self):
|
|
self.closed = True
|
|
for page in self.pages:
|
|
page.closed = True
|
|
|
|
|
|
class FakeBrowser:
|
|
def __init__(self):
|
|
self.pages = []
|
|
self.contexts = []
|
|
self.context = types.SimpleNamespace(request=object())
|
|
|
|
async def new_page(self):
|
|
page = FakePage(self.context)
|
|
self.pages.append(page)
|
|
return page
|
|
|
|
async def new_context(self):
|
|
context = FakeContext()
|
|
self.contexts.append(context)
|
|
return context
|
|
|
|
|
|
class FakePair:
|
|
def __init__(self):
|
|
self.t = types.SimpleNamespace(value="tok")
|
|
self.q = types.SimpleNamespace(
|
|
value={"email": "e@example.test", "password": "pw", "code": "123456"}
|
|
)
|
|
|
|
|
|
class FakeInventory:
|
|
def __init__(self):
|
|
self.active = 0
|
|
self.claims = 0
|
|
self.t_depth = 0
|
|
self.q_depth = 0
|
|
|
|
def claim_pair(self):
|
|
inventory = self
|
|
|
|
class Claim:
|
|
async def __aenter__(self):
|
|
inventory.active += 1
|
|
inventory.claims += 1
|
|
return FakePair()
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
inventory.active -= 1
|
|
return False
|
|
|
|
return Claim()
|
|
|
|
|
|
class RegisterRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
|
async def asyncSetUp(self):
|
|
self._old_stop = register.STOP
|
|
self._old_timeout = getattr(register, "C_CONSUME_TIMEOUT", None)
|
|
self._old_verify = register.grpc_verify_code
|
|
self._old_register = register.server_action_register
|
|
self._old_create_code = register.grpc_create_code
|
|
self._old_poll_code = register.poll_code
|
|
self._old_poll_code_async = register._poll_code_async
|
|
self._old_log = register.log
|
|
self._old_target = register.TARGET
|
|
self._old_c_hot_page_pool = getattr(register, "C_HOT_PAGE_POOL", None)
|
|
self._old_c_hot_page_pool_size = getattr(register, "C_HOT_PAGE_POOL_SIZE", None)
|
|
self._old_c_set_cookie_via_request = getattr(register, "C_SET_COOKIE_VIA_REQUEST", None)
|
|
self._old_log_mode = getattr(register, "REGISTER_LOG_MODE", None)
|
|
self._old_rate_limit_circuit = register.REGISTRATION_RATE_LIMIT_CIRCUIT
|
|
|
|
async def asyncTearDown(self):
|
|
if hasattr(register, "_close_c_hot_page_pool"):
|
|
await register._close_c_hot_page_pool()
|
|
register.STOP = self._old_stop
|
|
if self._old_timeout is not None:
|
|
register.C_CONSUME_TIMEOUT = self._old_timeout
|
|
register.grpc_verify_code = self._old_verify
|
|
register.server_action_register = self._old_register
|
|
register.grpc_create_code = self._old_create_code
|
|
register.poll_code = self._old_poll_code
|
|
register._poll_code_async = self._old_poll_code_async
|
|
register.log = self._old_log
|
|
register.TARGET = self._old_target
|
|
if self._old_c_hot_page_pool is None and hasattr(register, "C_HOT_PAGE_POOL"):
|
|
delattr(register, "C_HOT_PAGE_POOL")
|
|
elif self._old_c_hot_page_pool is not None:
|
|
register.C_HOT_PAGE_POOL = self._old_c_hot_page_pool
|
|
if self._old_c_hot_page_pool_size is None and hasattr(register, "C_HOT_PAGE_POOL_SIZE"):
|
|
delattr(register, "C_HOT_PAGE_POOL_SIZE")
|
|
elif self._old_c_hot_page_pool_size is not None:
|
|
register.C_HOT_PAGE_POOL_SIZE = self._old_c_hot_page_pool_size
|
|
if self._old_c_set_cookie_via_request is None and hasattr(register, "C_SET_COOKIE_VIA_REQUEST"):
|
|
delattr(register, "C_SET_COOKIE_VIA_REQUEST")
|
|
elif self._old_c_set_cookie_via_request is not None:
|
|
register.C_SET_COOKIE_VIA_REQUEST = self._old_c_set_cookie_via_request
|
|
if self._old_log_mode is None and hasattr(register, "REGISTER_LOG_MODE"):
|
|
delattr(register, "REGISTER_LOG_MODE")
|
|
elif self._old_log_mode is not None:
|
|
register.REGISTER_LOG_MODE = self._old_log_mode
|
|
register.REGISTRATION_RATE_LIMIT_CIRCUIT = self._old_rate_limit_circuit
|
|
|
|
async def test_c_worker_timeout_releases_physical_and_pair_and_counts_failure(self):
|
|
async def slow_verify(*_args, **_kwargs):
|
|
await asyncio.sleep(10)
|
|
return True
|
|
|
|
register.STOP = asyncio.Event()
|
|
register.C_CONSUME_TIMEOUT = 0.05
|
|
register.grpc_verify_code = slow_verify
|
|
register.server_action_register = lambda *_args, **_kwargs: None
|
|
register.log = lambda _msg: None
|
|
|
|
inventory = FakeInventory()
|
|
physical_sem = asyncio.Semaphore(1)
|
|
metrics = Metrics()
|
|
|
|
task = asyncio.create_task(
|
|
register.c_worker(0, FakeBrowser(), inventory, physical_sem, metrics)
|
|
)
|
|
await asyncio.sleep(0.12)
|
|
register.STOP.set()
|
|
await asyncio.wait_for(task, timeout=1)
|
|
|
|
self.assertEqual(physical_sem._value, 1)
|
|
self.assertEqual(inventory.active, 0)
|
|
self.assertEqual(metrics.pair_consumed_fail, 1)
|
|
|
|
async def test_c_consume_uses_single_use_page_by_default(self):
|
|
async def ok_verify(*_args, **_kwargs):
|
|
return True
|
|
|
|
async def no_sso_register(*_args, **_kwargs):
|
|
return None
|
|
|
|
register.C_HOT_PAGE_POOL = False
|
|
register.grpc_verify_code = ok_verify
|
|
register.server_action_register = no_sso_register
|
|
register.log = lambda _msg: None
|
|
|
|
browser = FakeBrowser()
|
|
physical_sem = asyncio.Semaphore(1)
|
|
|
|
ok = await register._consume_pair(browser, physical_sem, FakePair(), Metrics())
|
|
|
|
self.assertFalse(ok)
|
|
self.assertEqual(len(browser.pages), 1)
|
|
self.assertEqual(len(browser.contexts), 0)
|
|
self.assertTrue(browser.pages[0].closed)
|
|
self.assertEqual(physical_sem._value, 1)
|
|
|
|
async def test_c_hot_page_reuses_page_and_clears_cookies_between_consumes(self):
|
|
seen_pages = []
|
|
|
|
async def ok_verify(*_args, **_kwargs):
|
|
return True
|
|
|
|
async def no_sso_register(page, *_args, **_kwargs):
|
|
seen_pages.append(page)
|
|
return None
|
|
|
|
register.C_HOT_PAGE_POOL = True
|
|
register.C_HOT_PAGE_POOL_SIZE = 2
|
|
register.grpc_verify_code = ok_verify
|
|
register.server_action_register = no_sso_register
|
|
register.log = lambda _msg: None
|
|
|
|
browser = FakeBrowser()
|
|
physical_sem = asyncio.Semaphore(1)
|
|
|
|
first = await register._consume_pair(browser, physical_sem, FakePair(), Metrics())
|
|
second = await register._consume_pair(browser, physical_sem, FakePair(), Metrics())
|
|
|
|
self.assertFalse(first)
|
|
self.assertFalse(second)
|
|
self.assertEqual(len(browser.contexts), 1)
|
|
self.assertEqual(len(browser.contexts[0].pages), 1)
|
|
self.assertEqual(seen_pages, [browser.contexts[0].pages[0], browser.contexts[0].pages[0]])
|
|
self.assertEqual(browser.contexts[0].clear_cookies_calls, 2)
|
|
self.assertFalse(browser.contexts[0].closed)
|
|
self.assertFalse(browser.contexts[0].pages[0].closed)
|
|
self.assertEqual(physical_sem._value, 1)
|
|
|
|
async def test_c_hot_page_discards_page_after_exception(self):
|
|
async def failing_verify(*_args, **_kwargs):
|
|
raise RuntimeError("verify failed")
|
|
|
|
register.C_HOT_PAGE_POOL = True
|
|
register.C_HOT_PAGE_POOL_SIZE = 2
|
|
register.grpc_verify_code = failing_verify
|
|
register.log = lambda _msg: None
|
|
|
|
browser = FakeBrowser()
|
|
physical_sem = asyncio.Semaphore(1)
|
|
|
|
with self.assertRaises(RuntimeError):
|
|
await register._consume_pair(browser, physical_sem, FakePair(), Metrics())
|
|
|
|
self.assertEqual(len(browser.contexts), 1)
|
|
self.assertTrue(browser.contexts[0].closed)
|
|
self.assertTrue(browser.contexts[0].pages[0].closed)
|
|
self.assertEqual(physical_sem._value, 1)
|
|
|
|
async def test_c_hot_page_closes_context_when_cancelled_during_cleanup(self):
|
|
context = FakeContext()
|
|
page = await context.new_page()
|
|
page.url = "https://accounts.x.ai/sign-up?redirect=grok-com"
|
|
context.cancel_on_clear = True
|
|
register.C_HOT_PAGE_POOL = True
|
|
|
|
with self.assertRaises(asyncio.CancelledError):
|
|
await register._release_c_page(context, page, healthy=True)
|
|
|
|
self.assertTrue(context.closed)
|
|
self.assertTrue(page.closed)
|
|
|
|
async def test_c_hot_page_discards_page_after_fallback_navigation(self):
|
|
context = FakeContext()
|
|
page = await context.new_page()
|
|
page.url = "https://example.test/set-cookie?q=abc"
|
|
register.C_HOT_PAGE_POOL = True
|
|
register.C_HOT_PAGE_POOL_SIZE = 2
|
|
|
|
await register._release_c_page(context, page, healthy=True)
|
|
|
|
self.assertTrue(context.closed)
|
|
self.assertTrue(page.closed)
|
|
self.assertEqual(register._c_hot_page_pool, [])
|
|
|
|
async def test_server_action_can_set_cookie_via_request_without_navigating_page(self):
|
|
page = FakePage()
|
|
context = FakeContext()
|
|
context.cookies_value = [{"name": "sso", "value": "x" * 152}]
|
|
page.context = context
|
|
page.goto_calls = []
|
|
|
|
async def fake_evaluate(_script):
|
|
return '0:"https:\\/\\/auth.grokipedia.com\\/set-cookie?q=abc"1:'
|
|
|
|
page.evaluate = fake_evaluate
|
|
register.STATE_TREE = "state"
|
|
register.ACTION_ID = "action"
|
|
register.C_SET_COOKIE_VIA_REQUEST = True
|
|
|
|
sso = await register.server_action_register(
|
|
page, "e@example.test", "pw", "123456", "token"
|
|
)
|
|
|
|
self.assertEqual(sso, "x" * 152)
|
|
self.assertEqual(page.goto_calls, [])
|
|
self.assertEqual(
|
|
context.request_get_calls,
|
|
[{"url": "https://auth.grokipedia.com/set-cookie?q=abc", "timeout": 15000}],
|
|
)
|
|
|
|
async def test_server_action_raises_a_distinct_error_for_a_rate_limited_signup_page(self):
|
|
page = FakePage()
|
|
|
|
async def rate_limited_response(_script):
|
|
return "Too many requests. Please try again later."
|
|
|
|
page.evaluate = rate_limited_response
|
|
register.STATE_TREE = "state"
|
|
register.ACTION_ID = "action"
|
|
|
|
with self.assertRaises(register.RegistrationRateLimited):
|
|
await register.server_action_register(
|
|
page, "e@example.test", "pw", "123456", "token"
|
|
)
|
|
|
|
async def test_rate_limit_circuit_opens_for_the_configured_cooldown(self):
|
|
now = [100.0]
|
|
circuit = register.RegistrationRateLimitCircuit(
|
|
cooldown_seconds=60,
|
|
clock=lambda: now[0],
|
|
)
|
|
|
|
circuit.trip()
|
|
|
|
self.assertTrue(circuit.is_open())
|
|
self.assertEqual(circuit.remaining_seconds(), 60)
|
|
now[0] = 160.0
|
|
self.assertFalse(circuit.is_open())
|
|
|
|
async def test_monitor_uses_metrics_snapshot(self):
|
|
register.STOP = asyncio.Event()
|
|
register.TARGET = 1
|
|
register.REGISTER_LOG_MODE = "debug"
|
|
messages = []
|
|
register.log = messages.append
|
|
|
|
metrics = Metrics()
|
|
metrics.success_count = 1
|
|
metrics.pair_claimed = 2
|
|
metrics.pair_consumed_ok = 1
|
|
metrics.pair_consumed_fail = 1
|
|
sems = {
|
|
"physical": asyncio.Semaphore(1),
|
|
"t_slot": asyncio.Semaphore(1),
|
|
"q_slot": asyncio.Semaphore(1),
|
|
"q_pending": asyncio.Semaphore(1),
|
|
}
|
|
|
|
await register.monitor(FakeInventory(), sems, metrics, interval=0)
|
|
|
|
self.assertTrue(
|
|
any("pair:2 ok:1 fail:1" in message for message in messages),
|
|
messages,
|
|
)
|
|
|
|
async def test_monitor_hides_internal_snapshot_in_user_mode(self):
|
|
register.STOP = asyncio.Event()
|
|
register.TARGET = 1
|
|
register.REGISTER_LOG_MODE = "user"
|
|
messages = []
|
|
register.log = messages.append
|
|
|
|
metrics = Metrics()
|
|
metrics.success_count = 1
|
|
sems = {
|
|
"physical": asyncio.Semaphore(1),
|
|
"t_slot": asyncio.Semaphore(1),
|
|
"q_slot": asyncio.Semaphore(1),
|
|
"q_pending": asyncio.Semaphore(1),
|
|
}
|
|
|
|
await register.monitor(FakeInventory(), sems, metrics, interval=0)
|
|
|
|
self.assertFalse(any(message.startswith("[*] T:") for message in messages), messages)
|
|
|
|
async def test_user_event_format_reports_only_registration_outcomes(self):
|
|
self.assertEqual(
|
|
register.format_user_registration_event("started", task_id=7),
|
|
"[→] 开始注册 #7",
|
|
)
|
|
self.assertEqual(
|
|
register.format_user_registration_event(
|
|
"success", task_id=7, count=5, rate_per_minute=12.34
|
|
),
|
|
"[✓] 注册成功 #7 | 运行平均 12.3/分 | 累计 5",
|
|
)
|
|
self.assertEqual(
|
|
register.format_user_registration_event("failed", task_id=7),
|
|
"[✗] 注册失败 #7 | 已跳过,继续下一任务",
|
|
)
|
|
self.assertEqual(
|
|
register.format_user_registration_event("rate_limited", wait_seconds=60),
|
|
"[⏸] 触发限流 | 60秒后恢复探测",
|
|
)
|
|
self.assertEqual(
|
|
register.format_user_registration_event("recovered", wait_seconds=61),
|
|
"[▶] 限流解除 | 实际等待 61秒",
|
|
)
|
|
|
|
async def test_debug_flag_overrides_environment_and_invalid_mode_is_rejected(self):
|
|
self.assertEqual(
|
|
register.resolve_register_log_mode(["--debug"], {"REGISTER_LOG_MODE": "user"}),
|
|
"debug",
|
|
)
|
|
self.assertEqual(
|
|
register.resolve_register_log_mode([], {"REGISTER_LOG_MODE": "debug"}),
|
|
"debug",
|
|
)
|
|
with self.assertRaises(ValueError):
|
|
register.resolve_register_log_mode([], {"REGISTER_LOG_MODE": "verbose"})
|
|
|
|
async def test_registration_task_numbers_are_separate_from_pair_claims(self):
|
|
metrics = Metrics()
|
|
metrics.pair_claimed = 12
|
|
|
|
self.assertEqual(metrics.next_registration_task(), 1)
|
|
self.assertEqual(metrics.next_registration_task(), 2)
|
|
|
|
async def test_five_minute_rate_uses_process_uptime_then_sliding_window(self):
|
|
now = [0.0]
|
|
metrics = Metrics(clock=lambda: now[0])
|
|
self.assertIsNone(metrics.five_minute_success_rate())
|
|
|
|
now[0] = 10.0
|
|
metrics.record_success()
|
|
now[0] = 20.0
|
|
metrics.record_success()
|
|
self.assertEqual(metrics.five_minute_success_rate(), 6.0)
|
|
|
|
now[0] = 311.0
|
|
self.assertAlmostEqual(metrics.five_minute_success_rate(), 0.2)
|
|
now[0] = 321.0
|
|
self.assertEqual(metrics.five_minute_success_rate(), 0.0)
|
|
|
|
async def test_runtime_average_rate_includes_the_entire_cooldown_period(self):
|
|
now = [0.0]
|
|
metrics = Metrics(clock=lambda: now[0])
|
|
self.assertIsNone(metrics.runtime_average_success_rate())
|
|
|
|
now[0] = 10.0
|
|
metrics.record_success()
|
|
now[0] = 20.0
|
|
metrics.record_success()
|
|
self.assertEqual(metrics.runtime_average_success_rate(), 6.0)
|
|
|
|
now[0] = 320.0
|
|
self.assertEqual(metrics.runtime_average_success_rate(), 0.375)
|
|
|
|
async def test_terminal_output_failure_does_not_escape_log(self):
|
|
old_output = register._terminal_output
|
|
try:
|
|
register._terminal_output = lambda _message: (_ for _ in ()).throw(
|
|
OSError("closed pipe")
|
|
)
|
|
register.log("safe")
|
|
finally:
|
|
register._terminal_output = old_output
|
|
|
|
async def test_rate_limit_circuit_measures_one_recovery_window(self):
|
|
now = [100.0]
|
|
circuit = register.RegistrationRateLimitCircuit(
|
|
cooldown_seconds=60,
|
|
clock=lambda: now[0],
|
|
)
|
|
circuit.trip()
|
|
now[0] = 161.5
|
|
|
|
self.assertEqual(circuit.consume_recovery_seconds(), 61.5)
|
|
self.assertIsNone(circuit.consume_recovery_seconds())
|
|
|
|
async def test_rate_limit_probe_is_released_when_consume_times_out(self):
|
|
async def slow_verify(*_args, **_kwargs):
|
|
await asyncio.Event().wait()
|
|
|
|
register.REGISTRATION_RATE_LIMIT_CIRCUIT = register.RegistrationRateLimitCircuit(0)
|
|
register.REGISTRATION_RATE_LIMIT_CIRCUIT.trip()
|
|
register.grpc_verify_code = slow_verify
|
|
|
|
with self.assertRaises(asyncio.TimeoutError):
|
|
await asyncio.wait_for(
|
|
register._consume_pair(
|
|
FakeBrowser(), asyncio.Semaphore(1), FakePair(), Metrics(), task_id=1
|
|
),
|
|
timeout=0.01,
|
|
)
|
|
|
|
self.assertFalse(register.REGISTRATION_RATE_LIMIT_CIRCUIT._probe_active)
|
|
|
|
async def test_auto_capacity_is_bounded_by_cpu_and_memory(self):
|
|
roomy = register.derive_capacity(
|
|
cpu_count=2,
|
|
max_mem_mb=5600,
|
|
physical_cap=0,
|
|
physical_per_cpu=4,
|
|
physical_mem_mb=512,
|
|
min_free_mem_mb=500,
|
|
)
|
|
tight = register.derive_capacity(
|
|
cpu_count=2,
|
|
max_mem_mb=1100,
|
|
physical_cap=0,
|
|
physical_per_cpu=4,
|
|
physical_mem_mb=512,
|
|
min_free_mem_mb=500,
|
|
)
|
|
|
|
self.assertEqual(roomy[0], 8)
|
|
self.assertEqual(roomy[1], 10)
|
|
self.assertEqual(roomy[2], register.Q_PENDING_CAP + 2)
|
|
self.assertEqual(roomy[3], 10)
|
|
self.assertEqual(tight[0], 1)
|
|
|
|
async def test_default_auto_capacity_is_conservative(self):
|
|
physical, s_workers, _p_workers, c_workers = register.derive_capacity(
|
|
cpu_count=2,
|
|
max_mem_mb=5600,
|
|
physical_cap=0,
|
|
)
|
|
|
|
self.assertEqual(physical, 4)
|
|
self.assertEqual(s_workers, 6)
|
|
self.assertEqual(c_workers, 6)
|
|
|
|
async def test_explicit_physical_cap_overrides_auto_capacity(self):
|
|
physical, s_workers, _p_workers, c_workers = register.derive_capacity(
|
|
cpu_count=2,
|
|
max_mem_mb=1100,
|
|
physical_cap=3,
|
|
physical_per_cpu=4,
|
|
physical_mem_mb=512,
|
|
min_free_mem_mb=500,
|
|
)
|
|
|
|
self.assertEqual(physical, 3)
|
|
self.assertEqual(s_workers, 5)
|
|
self.assertEqual(c_workers, 5)
|
|
|
|
async def test_capacity_profile_supplies_physical_cap_when_not_explicit(self):
|
|
physical, s_workers, _p_workers, c_workers = register.derive_capacity(
|
|
cpu_count=2,
|
|
max_mem_mb=5600,
|
|
physical_cap=0,
|
|
profile_physical_cap=7,
|
|
)
|
|
tight, *_ = register.derive_capacity(
|
|
cpu_count=2,
|
|
max_mem_mb=1100,
|
|
physical_cap=0,
|
|
profile_physical_cap=7,
|
|
)
|
|
explicit, *_ = register.derive_capacity(
|
|
cpu_count=2,
|
|
max_mem_mb=5600,
|
|
physical_cap=5,
|
|
profile_physical_cap=7,
|
|
)
|
|
|
|
self.assertEqual(physical, 7)
|
|
self.assertEqual(s_workers, 9)
|
|
self.assertEqual(c_workers, 9)
|
|
self.assertEqual(tight, 1)
|
|
self.assertEqual(explicit, 5)
|
|
|
|
async def test_load_capacity_profile_reads_valid_physical_cap(self):
|
|
with tempfile.NamedTemporaryFile("w+", delete=True) as f:
|
|
json.dump({"physical_cap": 7}, f)
|
|
f.flush()
|
|
|
|
profile = register.load_capacity_profile(f.name)
|
|
|
|
self.assertEqual(profile["physical_cap"], 7)
|
|
self.assertEqual(register.load_capacity_profile("/does/not/exist"), {})
|
|
self.assertEqual(register.load_capacity_profile(""), {})
|
|
|
|
async def test_admission_t_high_defaults_to_physical_cap_bounded_by_slot(self):
|
|
watermarks = register.derive_admission_watermarks(
|
|
physical_cap=6,
|
|
t_slot_cap=8,
|
|
q_pending_cap=12,
|
|
t_target=4,
|
|
q_target=4,
|
|
)
|
|
bounded = register.derive_admission_watermarks(
|
|
physical_cap=10,
|
|
t_slot_cap=8,
|
|
q_pending_cap=12,
|
|
t_target=4,
|
|
q_target=4,
|
|
)
|
|
|
|
self.assertEqual(watermarks["t_high"], 6)
|
|
self.assertEqual(watermarks["t_low"], 3)
|
|
self.assertEqual(bounded["t_high"], 8)
|
|
|
|
async def test_admission_t_high_override_remains_explicit(self):
|
|
watermarks = register.derive_admission_watermarks(
|
|
physical_cap=6,
|
|
t_slot_cap=8,
|
|
q_pending_cap=12,
|
|
t_target=4,
|
|
q_target=4,
|
|
t_high_override=4,
|
|
t_low_override=2,
|
|
)
|
|
|
|
self.assertEqual(watermarks["t_high"], 4)
|
|
self.assertEqual(watermarks["t_low"], 2)
|
|
|
|
async def test_c_hot_page_pool_size_is_derived_at_startup(self):
|
|
self.assertEqual(
|
|
register.derive_c_hot_page_pool_size(
|
|
physical_cap=6, c_workers=8, configured_size=0
|
|
),
|
|
6,
|
|
)
|
|
self.assertEqual(
|
|
register.derive_c_hot_page_pool_size(
|
|
physical_cap=8, c_workers=3, configured_size=0
|
|
),
|
|
3,
|
|
)
|
|
self.assertEqual(
|
|
register.derive_c_hot_page_pool_size(
|
|
physical_cap=8, c_workers=10, configured_size=4
|
|
),
|
|
4,
|
|
)
|
|
|
|
async def test_send_q_request_batch_reuses_one_page_for_multiple_emails(self):
|
|
emails = []
|
|
|
|
async def fake_create_code(_page, email):
|
|
emails.append(email)
|
|
return True
|
|
|
|
register.grpc_create_code = fake_create_code
|
|
browser = FakeBrowser()
|
|
physical_sem = asyncio.Semaphore(1)
|
|
p_send_sem = asyncio.Semaphore(1)
|
|
requests = [
|
|
{"handle": "h1", "email": "a@example.test", "password": "pw1"},
|
|
{"handle": "h2", "email": "b@example.test", "password": "pw2"},
|
|
{"handle": "h3", "email": "c@example.test", "password": "pw3"},
|
|
]
|
|
|
|
results = await register._send_q_request_batch(
|
|
browser, physical_sem, p_send_sem, requests
|
|
)
|
|
|
|
self.assertEqual(emails, [item["email"] for item in requests])
|
|
self.assertEqual([item["sent"] for item in results], [True, True, True])
|
|
self.assertEqual(len(browser.pages), 1)
|
|
self.assertTrue(browser.pages[0].closed)
|
|
self.assertEqual(physical_sem._value, 1)
|
|
self.assertEqual(p_send_sem._value, 1)
|
|
|
|
async def test_send_q_request_batch_records_physical_and_stage_metrics(self):
|
|
async def fake_create_code(_page, _email):
|
|
return True
|
|
|
|
register.grpc_create_code = fake_create_code
|
|
browser = FakeBrowser()
|
|
physical_sem = asyncio.Semaphore(1)
|
|
p_send_sem = asyncio.Semaphore(1)
|
|
metrics = Metrics()
|
|
|
|
await register._send_q_request_batch(
|
|
browser,
|
|
physical_sem,
|
|
p_send_sem,
|
|
[{"handle": "h1", "email": "a@example.test", "password": "pw"}],
|
|
metrics,
|
|
)
|
|
|
|
self.assertEqual(metrics.p_physical_count, 1)
|
|
self.assertEqual(metrics.p_page_prepare_count, 1)
|
|
self.assertEqual(metrics.p_send_count, 1)
|
|
self.assertGreaterEqual(metrics.p_physical_wait_seconds, 0)
|
|
self.assertGreaterEqual(metrics.p_physical_hold_seconds, 0)
|
|
|
|
async def test_consume_pair_records_physical_and_stage_metrics(self):
|
|
async def ok_verify(*_args, **_kwargs):
|
|
return True
|
|
|
|
async def no_sso_register(*_args, **_kwargs):
|
|
return None
|
|
|
|
register.C_HOT_PAGE_POOL = False
|
|
register.grpc_verify_code = ok_verify
|
|
register.server_action_register = no_sso_register
|
|
register.log = lambda _msg: None
|
|
metrics = Metrics()
|
|
|
|
await register._consume_pair(
|
|
FakeBrowser(), asyncio.Semaphore(1), FakePair(), metrics
|
|
)
|
|
|
|
self.assertEqual(metrics.c_physical_count, 1)
|
|
self.assertEqual(metrics.c_page_acquire_count, 1)
|
|
self.assertEqual(metrics.c_verify_count, 1)
|
|
self.assertEqual(metrics.c_register_count, 1)
|
|
self.assertEqual(metrics.c_hot_page_hits, 0)
|
|
self.assertEqual(metrics.c_hot_page_misses, 0)
|
|
|
|
async def test_poll_and_admit_q_releases_one_pending_per_terminal_request(self):
|
|
register.P_REQUEST_TIMEOUT = 1
|
|
register.poll_code = lambda _handle: None
|
|
q_pending_sem = asyncio.Semaphore(0)
|
|
q_slot_sem = asyncio.Semaphore(1)
|
|
metrics = Metrics()
|
|
|
|
await register._poll_and_admit_q(
|
|
{"handle": "h1", "email": "a@example.test", "password": "pw"},
|
|
FakeInventory(),
|
|
q_pending_sem,
|
|
q_slot_sem,
|
|
metrics,
|
|
)
|
|
|
|
self.assertEqual(q_pending_sem._value, 1)
|
|
self.assertEqual(q_slot_sem._value, 1)
|
|
self.assertEqual(metrics.q_discarded, 1)
|
|
|
|
async def test_poll_cancel_before_terminal_does_not_release_pending(self):
|
|
async def blocked_poll(_loop, _handle):
|
|
await asyncio.sleep(10)
|
|
return "123456"
|
|
|
|
register._poll_code_async = blocked_poll
|
|
q_pending_sem = asyncio.Semaphore(0)
|
|
q_slot_sem = asyncio.Semaphore(1)
|
|
metrics = Metrics()
|
|
|
|
task = asyncio.create_task(
|
|
register._poll_and_admit_q(
|
|
{"handle": "h1", "email": "a@example.test", "password": "pw"},
|
|
FakeInventory(),
|
|
q_pending_sem,
|
|
q_slot_sem,
|
|
metrics,
|
|
)
|
|
)
|
|
await asyncio.sleep(0.02)
|
|
task.cancel()
|
|
with self.assertRaises(asyncio.CancelledError):
|
|
await task
|
|
|
|
self.assertEqual(q_pending_sem._value, 0)
|
|
self.assertEqual(q_slot_sem._value, 1)
|
|
|
|
async def test_poll_cancel_after_q_return_releases_pending(self):
|
|
async def returned_poll(_loop, _handle):
|
|
return "123456"
|
|
|
|
class BlockingInventory(FakeInventory):
|
|
async def put_q(self, _env):
|
|
await asyncio.sleep(10)
|
|
|
|
register._poll_code_async = returned_poll
|
|
q_pending_sem = asyncio.Semaphore(0)
|
|
q_slot_sem = asyncio.Semaphore(1)
|
|
metrics = Metrics()
|
|
|
|
task = asyncio.create_task(
|
|
register._poll_and_admit_q(
|
|
{"handle": "h1", "email": "a@example.test", "password": "pw"},
|
|
BlockingInventory(),
|
|
q_pending_sem,
|
|
q_slot_sem,
|
|
metrics,
|
|
)
|
|
)
|
|
await asyncio.sleep(0.02)
|
|
task.cancel()
|
|
with self.assertRaises(asyncio.CancelledError):
|
|
await task
|
|
|
|
self.assertEqual(q_pending_sem._value, 1)
|
|
self.assertEqual(q_slot_sem._value, 1)
|
|
self.assertEqual(metrics.q_returned, 1)
|
|
|
|
async def test_metrics_snapshot_includes_solver_timing(self):
|
|
metrics = Metrics()
|
|
metrics.t_solve_count = 2
|
|
metrics.t_solve_seconds = 5.0
|
|
metrics.t_solve_failed = 1
|
|
sems = {
|
|
"physical": asyncio.Semaphore(1),
|
|
"t_slot": asyncio.Semaphore(1),
|
|
"q_slot": asyncio.Semaphore(1),
|
|
"q_pending": asyncio.Semaphore(1),
|
|
}
|
|
|
|
row = metrics.snapshot(FakeInventory(), sems)
|
|
|
|
self.assertIn("t_solve_avg:2.5", row)
|
|
self.assertIn("t_solve_fail:1", row)
|
|
|
|
async def test_metrics_snapshot_includes_role_physical_and_stage_timing(self):
|
|
metrics = Metrics()
|
|
metrics.s_physical_count = 2
|
|
metrics.s_physical_wait_seconds = 1.0
|
|
metrics.s_physical_hold_seconds = 6.0
|
|
metrics.p_physical_count = 1
|
|
metrics.p_physical_wait_seconds = 0.2
|
|
metrics.p_physical_hold_seconds = 1.4
|
|
metrics.c_physical_count = 3
|
|
metrics.c_physical_wait_seconds = 0.9
|
|
metrics.c_physical_hold_seconds = 7.5
|
|
metrics.p_email_create_count = 2
|
|
metrics.p_email_create_seconds = 1.0
|
|
metrics.p_page_prepare_count = 1
|
|
metrics.p_page_prepare_seconds = 0.8
|
|
metrics.p_send_count = 1
|
|
metrics.p_send_seconds = 0.4
|
|
metrics.c_page_acquire_count = 2
|
|
metrics.c_page_acquire_seconds = 0.6
|
|
metrics.c_verify_count = 2
|
|
metrics.c_verify_seconds = 0.8
|
|
metrics.c_register_count = 2
|
|
metrics.c_register_seconds = 3.0
|
|
metrics.c_hot_page_hits = 4
|
|
metrics.c_hot_page_misses = 1
|
|
sems = {
|
|
"physical": asyncio.Semaphore(1),
|
|
"t_slot": asyncio.Semaphore(1),
|
|
"q_slot": asyncio.Semaphore(1),
|
|
"q_pending": asyncio.Semaphore(1),
|
|
}
|
|
|
|
row = metrics.snapshot(FakeInventory(), sems)
|
|
|
|
self.assertIn("s_phys:0.50/3.00", row)
|
|
self.assertIn("p_phys:0.20/1.40", row)
|
|
self.assertIn("c_phys:0.30/2.50", row)
|
|
self.assertIn("p_stage:0.50/0.80/0.40", row)
|
|
self.assertIn("c_stage:0.30/0.40/1.50", row)
|
|
self.assertIn("c_hot:4/1", row)
|
|
|
|
async def test_record_solver_trace_accumulates_stage_metrics(self):
|
|
metrics = Metrics()
|
|
|
|
register._record_solver_trace(
|
|
metrics,
|
|
{
|
|
"goto_s": 1.0,
|
|
"inject_s": 0.2,
|
|
"initial_s": 0.5,
|
|
"click_s": 0.1,
|
|
"wait_s": 20.0,
|
|
"reused": True,
|
|
"visible_frame": False,
|
|
},
|
|
21.8,
|
|
"token",
|
|
)
|
|
register._record_solver_trace(metrics, {}, 10.0, None)
|
|
|
|
self.assertEqual(metrics.t_solve_count, 2)
|
|
self.assertEqual(metrics.t_solve_failed, 1)
|
|
self.assertAlmostEqual(metrics.solver_goto_seconds, 1.0)
|
|
self.assertAlmostEqual(metrics.solver_wait_seconds, 20.0)
|
|
self.assertEqual(metrics.solver_reused_count, 1)
|
|
self.assertEqual(metrics.solver_visible_frame_count, 0)
|
|
|
|
async def test_solver_timeline_records_click_and_token_events_when_enabled(self):
|
|
page = FakePage()
|
|
original_click = register._mouse_click_turnstile_center_trace
|
|
original_read = register._read_turnstile_token
|
|
clicked_once = False
|
|
timeline = register._new_solver_timeline(enabled=True)
|
|
|
|
async def fake_click(_page, **_kwargs):
|
|
nonlocal clicked_once
|
|
clicked_once = True
|
|
return True, {"box_eval_ms": 0.0}
|
|
|
|
async def fake_read(_page):
|
|
return "token-value-long" if clicked_once else ""
|
|
|
|
try:
|
|
register._mouse_click_turnstile_center_trace = fake_click
|
|
register._read_turnstile_token = fake_read
|
|
|
|
clicked = await register._repeat_mouse_click_turnstile(page, timeline=timeline)
|
|
finally:
|
|
register._mouse_click_turnstile_center_trace = original_click
|
|
register._read_turnstile_token = original_read
|
|
|
|
events = [event["event"] for event in timeline["events"]]
|
|
self.assertTrue(clicked)
|
|
self.assertIn("click_before", events)
|
|
self.assertIn("click_after", events)
|
|
self.assertTrue(any(event.get("dom", {}).get("widget", {}).get("present") for event in timeline["events"]))
|
|
self.assertTrue(any(event.get("click_call_ms", 0) >= 0 for event in timeline["events"]))
|
|
self.assertTrue(any(event.get("token_len", 0) > 10 for event in timeline["events"]))
|
|
|
|
async def test_turnstile_dom_snapshot_reports_click_target_without_text_or_full_urls(self):
|
|
page = FakePage()
|
|
|
|
snapshot = await register._turnstile_dom_snapshot(page)
|
|
|
|
self.assertEqual(snapshot["all_iframe_count"], 1)
|
|
self.assertEqual(snapshot["turnstile_iframe_count"], 1)
|
|
self.assertEqual(snapshot["widget"]["w"], 300)
|
|
self.assertEqual(snapshot["element_at_center"]["tag"], "IFRAME")
|
|
self.assertEqual(snapshot["iframe_summaries"][0]["host"], "challenges.cloudflare.com")
|
|
self.assertNotIn("src", snapshot["iframe_summaries"][0])
|
|
self.assertNotIn("text", snapshot["element_at_center"])
|
|
|
|
async def test_mouse_click_turnstile_center_can_return_timing_trace(self):
|
|
page = FakePage()
|
|
|
|
clicked, trace = await register._mouse_click_turnstile_center_trace(page)
|
|
|
|
self.assertTrue(clicked)
|
|
self.assertEqual(trace["click_x"], 160.0)
|
|
self.assertEqual(trace["click_y"], 45.0)
|
|
for key in ("box_eval_ms", "mouse_move1_ms", "mouse_move2_ms", "mouse_down_ms", "mouse_up_ms"):
|
|
self.assertIn(key, trace)
|
|
self.assertGreaterEqual(trace[key], 0)
|
|
|
|
async def test_inject_turnstile_widget_leaves_default_script_uninstrumented(self):
|
|
page = FakePage()
|
|
|
|
await register._inject_turnstile_widget(page)
|
|
|
|
script = page.evaluations[-1]
|
|
self.assertNotIn("__cspTurnstileTrace", script)
|
|
|
|
async def test_inject_turnstile_widget_records_page_timeline_when_enabled(self):
|
|
page = FakePage()
|
|
|
|
await register._inject_turnstile_widget(page, timeline=True)
|
|
|
|
script = page.evaluations[-1]
|
|
self.assertIn("__cspTurnstileTrace", script)
|
|
self.assertIn("script_inserted_at", script)
|
|
self.assertIn("render_called_at", script)
|
|
self.assertIn("token_written_at", script)
|
|
|
|
async def test_start_turnstile_challenge_records_page_trace_when_timeline_enabled(self):
|
|
browser = FakeBrowser()
|
|
messages = []
|
|
old_trace = register.SOLVER_TIMELINE_TRACE
|
|
old_sample = register.SOLVER_TIMELINE_SAMPLE
|
|
old_emitted = register._solver_timeline_emitted
|
|
old_log = register.log
|
|
try:
|
|
register.SOLVER_TIMELINE_TRACE = True
|
|
register.SOLVER_TIMELINE_SAMPLE = 1
|
|
register._solver_timeline_emitted = 0
|
|
register.log = messages.append
|
|
|
|
item = await register._start_turnstile_challenge(browser, fast_click=True)
|
|
await register._put_solver_page(item, False)
|
|
finally:
|
|
register.SOLVER_TIMELINE_TRACE = old_trace
|
|
register.SOLVER_TIMELINE_SAMPLE = old_sample
|
|
register._solver_timeline_emitted = old_emitted
|
|
register.log = old_log
|
|
|
|
events = item["timeline"]["events"]
|
|
self.assertTrue(any(event["event"] == "page_trace_after_inject" for event in events))
|
|
self.assertTrue(any(event["event"] == "page_trace_after_click" for event in events))
|
|
|
|
async def test_solve_one_turnstile_uses_fast_click_by_default(self):
|
|
calls = []
|
|
|
|
async def fake_start(_browser, *, fast_click=False):
|
|
calls.append(fast_click)
|
|
return {"page": object()}
|
|
|
|
async def fake_wait(_item):
|
|
return "token-value"
|
|
|
|
old_start = register._start_turnstile_challenge
|
|
old_wait = register._wait_turnstile_challenge
|
|
try:
|
|
register._start_turnstile_challenge = fake_start
|
|
register._wait_turnstile_challenge = fake_wait
|
|
|
|
token = await register.solve_one_turnstile(object())
|
|
finally:
|
|
register._start_turnstile_challenge = old_start
|
|
register._wait_turnstile_challenge = old_wait
|
|
|
|
self.assertEqual(token, "token-value")
|
|
self.assertEqual(calls, [True])
|
|
|
|
async def test_wait_turnstile_logs_timeline_when_present(self):
|
|
messages = []
|
|
register.REGISTER_LOG_MODE = "debug"
|
|
|
|
async def fake_poll(_page, **_kwargs):
|
|
return "token-value-long"
|
|
|
|
async def fake_put(_item, _ok):
|
|
return None
|
|
|
|
old_poll = register._poll_turnstile_token
|
|
old_put = register._put_solver_page
|
|
old_log = register.log
|
|
try:
|
|
register._poll_turnstile_token = fake_poll
|
|
register._put_solver_page = fake_put
|
|
register.log = messages.append
|
|
item = {
|
|
"page": object(),
|
|
"trace": {},
|
|
"timeline": {
|
|
"start": register.time.time(),
|
|
"events": [{"t": 0.1, "event": "x"}],
|
|
},
|
|
}
|
|
|
|
token = await register._wait_turnstile_challenge(item)
|
|
finally:
|
|
register._poll_turnstile_token = old_poll
|
|
register._put_solver_page = old_put
|
|
register.log = old_log
|
|
|
|
self.assertEqual(token, "token-value-long")
|
|
self.assertTrue(any(message.startswith("[solver_timeline] ") for message in messages))
|
|
|
|
async def test_wait_turnstile_timeline_logs_solve_id_and_poll_summary(self):
|
|
messages = []
|
|
register.REGISTER_LOG_MODE = "debug"
|
|
page = FakePage()
|
|
page.turnstile_token = "token-value-long"
|
|
timeline = register._new_solver_timeline(enabled=True)
|
|
|
|
async def fake_put(_item, _ok):
|
|
return None
|
|
|
|
old_attempts = register.SOLVER_POLL_ATTEMPTS
|
|
old_interval = register.SOLVER_POLL_INTERVAL_MS
|
|
old_put = register._put_solver_page
|
|
old_sleep = register.asyncio.sleep
|
|
old_log = register.log
|
|
try:
|
|
register.SOLVER_POLL_ATTEMPTS = 1
|
|
register.SOLVER_POLL_INTERVAL_MS = 50
|
|
|
|
async def no_sleep(_seconds):
|
|
return None
|
|
|
|
register.asyncio.sleep = no_sleep
|
|
register._put_solver_page = fake_put
|
|
register.log = messages.append
|
|
|
|
token = await register._wait_turnstile_challenge({
|
|
"page": page,
|
|
"trace": {},
|
|
"timeline": timeline,
|
|
})
|
|
finally:
|
|
register.SOLVER_POLL_ATTEMPTS = old_attempts
|
|
register.SOLVER_POLL_INTERVAL_MS = old_interval
|
|
register.asyncio.sleep = old_sleep
|
|
register._put_solver_page = old_put
|
|
register.log = old_log
|
|
|
|
payload = next(message.removeprefix("[solver_timeline] ") for message in messages)
|
|
events = register.json.loads(payload)
|
|
poll_done = next(event for event in events if event["event"] == "poll_done")
|
|
|
|
self.assertEqual(token, "token-value-long")
|
|
self.assertIn("solve_id", poll_done)
|
|
self.assertEqual(poll_done["poll_attempts"], 1)
|
|
self.assertEqual(poll_done["first_token_attempt"], 1)
|
|
self.assertGreaterEqual(poll_done["poll_read_ms_max"], 0)
|
|
|
|
async def test_mouse_click_turnstile_retries_uses_center_clicks(self):
|
|
page = FakePage()
|
|
|
|
old_retries = getattr(register, "SOLVER_MOUSE_CLICK_RETRIES", None)
|
|
old_interval = getattr(register, "SOLVER_MOUSE_CLICK_INTERVAL_MS", None)
|
|
old_sleep = register.asyncio.sleep
|
|
try:
|
|
register.SOLVER_MOUSE_CLICK_RETRIES = 3
|
|
register.SOLVER_MOUSE_CLICK_INTERVAL_MS = 600
|
|
|
|
async def no_sleep(_seconds):
|
|
return None
|
|
|
|
register.asyncio.sleep = no_sleep
|
|
|
|
clicked = await register._repeat_mouse_click_turnstile(page)
|
|
finally:
|
|
register.asyncio.sleep = old_sleep
|
|
if old_retries is None:
|
|
delattr(register, "SOLVER_MOUSE_CLICK_RETRIES")
|
|
else:
|
|
register.SOLVER_MOUSE_CLICK_RETRIES = old_retries
|
|
if old_interval is None:
|
|
delattr(register, "SOLVER_MOUSE_CLICK_INTERVAL_MS")
|
|
else:
|
|
register.SOLVER_MOUSE_CLICK_INTERVAL_MS = old_interval
|
|
|
|
self.assertTrue(clicked)
|
|
self.assertEqual(page.mouse.downs, 3)
|
|
self.assertEqual(page.mouse.ups, 3)
|
|
self.assertEqual(page.mouse.moves[-1], {"x": 160, "y": 45, "steps": 8})
|
|
|
|
async def test_mouse_click_turnstile_stops_when_token_appears(self):
|
|
page = FakePage()
|
|
evaluate_count = 0
|
|
original_evaluate = page.evaluate
|
|
|
|
async def evaluate(script):
|
|
nonlocal evaluate_count
|
|
if "cf-turnstile-response" in script:
|
|
evaluate_count += 1
|
|
return "token-value-long" if evaluate_count > 1 else ""
|
|
return await original_evaluate(script)
|
|
|
|
page.evaluate = evaluate
|
|
|
|
old_retries = getattr(register, "SOLVER_MOUSE_CLICK_RETRIES", None)
|
|
old_interval = getattr(register, "SOLVER_MOUSE_CLICK_INTERVAL_MS", None)
|
|
old_sleep = register.asyncio.sleep
|
|
try:
|
|
register.SOLVER_MOUSE_CLICK_RETRIES = 3
|
|
register.SOLVER_MOUSE_CLICK_INTERVAL_MS = 600
|
|
|
|
async def no_sleep(_seconds):
|
|
return None
|
|
|
|
register.asyncio.sleep = no_sleep
|
|
|
|
clicked = await register._repeat_mouse_click_turnstile(page)
|
|
finally:
|
|
register.asyncio.sleep = old_sleep
|
|
if old_retries is None:
|
|
delattr(register, "SOLVER_MOUSE_CLICK_RETRIES")
|
|
else:
|
|
register.SOLVER_MOUSE_CLICK_RETRIES = old_retries
|
|
if old_interval is None:
|
|
delattr(register, "SOLVER_MOUSE_CLICK_INTERVAL_MS")
|
|
else:
|
|
register.SOLVER_MOUSE_CLICK_INTERVAL_MS = old_interval
|
|
|
|
self.assertTrue(clicked)
|
|
self.assertEqual(page.mouse.downs, 1)
|
|
self.assertEqual(evaluate_count, 2)
|
|
|
|
async def test_prepare_signup_page_uses_configured_navigation_profile(self):
|
|
page = FakePage()
|
|
|
|
old_wait_until = getattr(register, "PAGE_GOTO_WAIT_UNTIL", None)
|
|
old_post_wait = getattr(register, "PAGE_POST_WAIT_MS", None)
|
|
try:
|
|
register.PAGE_GOTO_WAIT_UNTIL = "domcontentloaded"
|
|
register.PAGE_POST_WAIT_MS = 500
|
|
|
|
await register._prepare_signup_page(page, redirect=True)
|
|
finally:
|
|
if old_wait_until is None:
|
|
delattr(register, "PAGE_GOTO_WAIT_UNTIL")
|
|
else:
|
|
register.PAGE_GOTO_WAIT_UNTIL = old_wait_until
|
|
if old_post_wait is None:
|
|
delattr(register, "PAGE_POST_WAIT_MS")
|
|
else:
|
|
register.PAGE_POST_WAIT_MS = old_post_wait
|
|
|
|
self.assertEqual(page.goto_calls[-1]["wait_until"], "domcontentloaded")
|
|
self.assertEqual(page.waits[-1], 500)
|
|
|
|
async def test_prepare_signup_page_can_block_static_assets(self):
|
|
page = FakePage()
|
|
|
|
old_block_assets = getattr(register, "PAGE_BLOCK_STATIC_ASSETS", None)
|
|
try:
|
|
register.PAGE_BLOCK_STATIC_ASSETS = True
|
|
|
|
await register._prepare_signup_page(page, redirect=True)
|
|
finally:
|
|
if old_block_assets is None:
|
|
delattr(register, "PAGE_BLOCK_STATIC_ASSETS")
|
|
else:
|
|
register.PAGE_BLOCK_STATIC_ASSETS = old_block_assets
|
|
|
|
self.assertEqual(len(page.route_calls), 1)
|
|
self.assertEqual(page.route_calls[0]["pattern"], "**/*")
|
|
|
|
async def test_default_solver_and_page_latency_profile_matches_accepted_optimization(self):
|
|
self.assertEqual(register.SOLVER_INITIAL_WAIT_MS, 500)
|
|
self.assertTrue(register.SOLVER_FAST_CLICK)
|
|
self.assertEqual(register.SOLVER_MOUSE_CLICK_RETRIES, 3)
|
|
self.assertEqual(register.SOLVER_MOUSE_CLICK_INTERVAL_MS, 600)
|
|
self.assertEqual(register.PAGE_GOTO_WAIT_UNTIL, "domcontentloaded")
|
|
self.assertEqual(register.PAGE_POST_WAIT_MS, 500)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|