424 lines
13 KiB
Python
424 lines
13 KiB
Python
import asyncio
|
|
import base64
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from urllib.error import URLError
|
|
from urllib.parse import urlencode
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("fastapi")
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from config import SERVER_CONFIG
|
|
import server as server_module
|
|
from server import create_app
|
|
|
|
|
|
class _FakePipeline:
|
|
def solve(self, image, captcha_type=None):
|
|
return {
|
|
"type": captcha_type or "normal",
|
|
"result": "A3B8",
|
|
"raw": "A3B8",
|
|
"time_ms": 1.23,
|
|
}
|
|
|
|
|
|
class _FakeFunPipeline:
|
|
def solve(self, image):
|
|
return {
|
|
"type": "funcaptcha",
|
|
"question": "4_3d_rollball_animals",
|
|
"objects": [2],
|
|
"result": "2",
|
|
"raw": "2",
|
|
"time_ms": 2.34,
|
|
}
|
|
|
|
|
|
def _create_test_app(funcaptcha_factories=None):
|
|
return create_app(
|
|
pipeline_factory=_FakePipeline,
|
|
funcaptcha_factories=funcaptcha_factories,
|
|
)
|
|
|
|
|
|
def _get_route(app, path: str):
|
|
for route in app.routes:
|
|
if getattr(route, "path", None) == path:
|
|
return route.endpoint
|
|
raise AssertionError(f"route not found: {path}")
|
|
|
|
|
|
def _fake_request(host: str = "127.0.0.1"):
|
|
return SimpleNamespace(client=SimpleNamespace(host=host))
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_server_config(monkeypatch, tmp_path):
|
|
monkeypatch.setitem(SERVER_CONFIG, "client_key", None)
|
|
monkeypatch.setitem(SERVER_CONFIG, "tasks_dir", str(tmp_path / "server_tasks"))
|
|
monkeypatch.setitem(SERVER_CONFIG, "task_cost", 0.0)
|
|
monkeypatch.setitem(SERVER_CONFIG, "callback_max_retries", 2)
|
|
monkeypatch.setitem(SERVER_CONFIG, "callback_retry_delay_seconds", 1.0)
|
|
monkeypatch.setitem(SERVER_CONFIG, "callback_retry_backoff", 2.0)
|
|
monkeypatch.setitem(SERVER_CONFIG, "callback_signing_secret", None)
|
|
|
|
|
|
def test_solve_base64_returns_sync_payload():
|
|
app = _create_test_app()
|
|
solve = _get_route(app, "/api/v1/solve")
|
|
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
|
|
|
|
response = asyncio.run(
|
|
solve(SimpleNamespace(image=encoded, type="math"))
|
|
)
|
|
|
|
assert response == {
|
|
"type": "math",
|
|
"result": "A3B8",
|
|
"raw": "A3B8",
|
|
"time_ms": 1.23,
|
|
}
|
|
|
|
|
|
def test_create_task_and_get_task_result():
|
|
app = _create_test_app()
|
|
create_task = _get_route(app, "/createTask")
|
|
get_task_result = _get_route(app, "/getTaskResult")
|
|
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
|
|
|
|
create_response = asyncio.run(
|
|
create_task(
|
|
SimpleNamespace(
|
|
clientKey="local",
|
|
task=SimpleNamespace(
|
|
type="ImageToTextTaskM1",
|
|
body=encoded,
|
|
image=None,
|
|
captchaType="normal",
|
|
),
|
|
),
|
|
_fake_request("10.0.0.8"),
|
|
)
|
|
)
|
|
|
|
assert create_response["errorId"] == 0
|
|
assert create_response["status"] == "processing"
|
|
assert isinstance(create_response["createTime"], int)
|
|
assert isinstance(create_response["expiresAt"], int)
|
|
task_id = create_response["taskId"]
|
|
|
|
result_response = None
|
|
for _ in range(20):
|
|
result_response = asyncio.run(
|
|
get_task_result(SimpleNamespace(clientKey="local", taskId=task_id))
|
|
)
|
|
if result_response.get("status") == "ready":
|
|
break
|
|
|
|
assert result_response is not None
|
|
assert result_response["errorId"] == 0
|
|
assert result_response["status"] == "ready"
|
|
assert result_response["solution"] == {
|
|
"text": "A3B8",
|
|
"answer": "A3B8",
|
|
"raw": "A3B8",
|
|
"captchaType": "normal",
|
|
"timeMs": 1.23,
|
|
}
|
|
assert result_response["cost"] == "0.00000"
|
|
assert result_response["ip"] == "10.0.0.8"
|
|
assert result_response["solveCount"] == 1
|
|
assert result_response["task"] == {
|
|
"type": "ImageToTextTaskM1",
|
|
"captchaType": "normal",
|
|
}
|
|
assert result_response["callback"] == {
|
|
"configured": False,
|
|
"url": None,
|
|
"attempts": 0,
|
|
"delivered": False,
|
|
"deliveredAt": None,
|
|
"lastError": None,
|
|
}
|
|
assert isinstance(result_response["expiresAt"], int)
|
|
|
|
|
|
def test_get_task_result_returns_not_found_for_unknown_task():
|
|
app = _create_test_app()
|
|
get_task_result = _get_route(app, "/getTaskResult")
|
|
|
|
response = asyncio.run(
|
|
get_task_result(SimpleNamespace(clientKey="local", taskId="missing-task"))
|
|
)
|
|
|
|
assert response["errorCode"] == "ERROR_TASK_NOT_FOUND"
|
|
|
|
|
|
def test_solve_returns_json_error_for_invalid_base64():
|
|
app = _create_test_app()
|
|
solve = _get_route(app, "/solve")
|
|
|
|
response = asyncio.run(
|
|
solve(SimpleNamespace(image="not_base64!", type="normal"))
|
|
)
|
|
|
|
assert isinstance(response, JSONResponse)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_health_alias_reports_client_key_flag(monkeypatch):
|
|
app = _create_test_app()
|
|
health = _get_route(app, "/api/v1/health")
|
|
|
|
monkeypatch.setitem(SERVER_CONFIG, "client_key", "secret")
|
|
response = health()
|
|
|
|
assert response["status"] == "ok"
|
|
assert response["client_key_required"] is True
|
|
assert "ImageToTextTask" in response["supported_task_types"]
|
|
|
|
|
|
def test_client_key_is_required_for_task_api(monkeypatch):
|
|
app = _create_test_app()
|
|
create_task = _get_route(app, "/api/v1/createTask")
|
|
get_balance = _get_route(app, "/api/v1/getBalance")
|
|
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
|
|
|
|
monkeypatch.setitem(SERVER_CONFIG, "client_key", "secret")
|
|
|
|
create_response = asyncio.run(
|
|
create_task(
|
|
SimpleNamespace(
|
|
clientKey="wrong-key",
|
|
task=SimpleNamespace(
|
|
type="ImageToTextTask",
|
|
body=encoded,
|
|
image=None,
|
|
captchaType="normal",
|
|
),
|
|
),
|
|
_fake_request(),
|
|
)
|
|
)
|
|
balance_response = asyncio.run(
|
|
get_balance(SimpleNamespace(clientKey="wrong-key"))
|
|
)
|
|
|
|
assert create_response["errorCode"] == "ERROR_KEY_DOES_NOT_EXIST"
|
|
assert balance_response["errorCode"] == "ERROR_KEY_DOES_NOT_EXIST"
|
|
|
|
|
|
def test_create_task_triggers_callback(monkeypatch):
|
|
app = _create_test_app()
|
|
create_task = _get_route(app, "/createTask")
|
|
callbacks = []
|
|
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
|
|
|
|
def _fake_post_callback(callback_url, payload):
|
|
callbacks.append((callback_url, payload))
|
|
|
|
monkeypatch.setattr(server_module, "_post_callback", _fake_post_callback)
|
|
|
|
response = asyncio.run(
|
|
create_task(
|
|
SimpleNamespace(
|
|
clientKey="local",
|
|
callbackUrl="https://example.com/callback",
|
|
task=SimpleNamespace(
|
|
type="ImageToTextTask",
|
|
body=encoded,
|
|
image=None,
|
|
captchaType="normal",
|
|
),
|
|
),
|
|
_fake_request("10.0.0.9"),
|
|
)
|
|
)
|
|
|
|
assert response["errorId"] == 0
|
|
|
|
for _ in range(20):
|
|
if callbacks:
|
|
break
|
|
time.sleep(0.01)
|
|
|
|
assert callbacks == [
|
|
(
|
|
"https://example.com/callback",
|
|
{
|
|
"id": response["taskId"],
|
|
"taskId": response["taskId"],
|
|
"status": "ready",
|
|
"errorId": "0",
|
|
"code": "A3B8",
|
|
"text": "A3B8",
|
|
"answer": "A3B8",
|
|
"raw": "A3B8",
|
|
"captchaType": "normal",
|
|
"timeMs": "1.23",
|
|
"cost": "0.00000",
|
|
},
|
|
)
|
|
]
|
|
|
|
|
|
def test_create_task_routes_fun_captcha_question():
|
|
app = _create_test_app(
|
|
funcaptcha_factories={"4_3d_rollball_animals": _FakeFunPipeline}
|
|
)
|
|
create_task = _get_route(app, "/createTask")
|
|
get_task_result = _get_route(app, "/getTaskResult")
|
|
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
|
|
|
|
create_response = asyncio.run(
|
|
create_task(
|
|
SimpleNamespace(
|
|
clientKey="local",
|
|
task=SimpleNamespace(
|
|
type="FunCaptcha",
|
|
body=encoded,
|
|
image=None,
|
|
captchaType=None,
|
|
question="4_3d_rollball_animals",
|
|
),
|
|
),
|
|
_fake_request("10.0.0.7"),
|
|
)
|
|
)
|
|
|
|
task_id = create_response["taskId"]
|
|
result_response = None
|
|
for _ in range(20):
|
|
result_response = asyncio.run(
|
|
get_task_result(SimpleNamespace(clientKey="local", taskId=task_id))
|
|
)
|
|
if result_response.get("status") == "ready":
|
|
break
|
|
|
|
assert result_response["errorId"] == 0
|
|
assert result_response["status"] == "ready"
|
|
assert result_response["solution"] == {
|
|
"objects": [2],
|
|
"answer": 2,
|
|
"raw": "2",
|
|
"timeMs": 2.34,
|
|
"question": "4_3d_rollball_animals",
|
|
"text": "2",
|
|
}
|
|
assert result_response["task"] == {
|
|
"type": "FunCaptcha",
|
|
"captchaType": None,
|
|
"question": "4_3d_rollball_animals",
|
|
}
|
|
|
|
|
|
def test_create_task_retries_callback(monkeypatch):
|
|
app = _create_test_app()
|
|
create_task = _get_route(app, "/createTask")
|
|
attempts = []
|
|
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
|
|
|
|
def _flaky_post_callback(callback_url, payload):
|
|
attempts.append((callback_url, payload["taskId"]))
|
|
if len(attempts) < 3:
|
|
raise URLError("temporary failure")
|
|
|
|
monkeypatch.setattr(server_module, "_post_callback", _flaky_post_callback)
|
|
monkeypatch.setitem(SERVER_CONFIG, "callback_retry_delay_seconds", 0.0)
|
|
monkeypatch.setitem(SERVER_CONFIG, "callback_retry_backoff", 1.0)
|
|
|
|
response = asyncio.run(
|
|
create_task(
|
|
SimpleNamespace(
|
|
clientKey="local",
|
|
callbackUrl="https://example.com/callback",
|
|
task=SimpleNamespace(
|
|
type="ImageToTextTask",
|
|
body=encoded,
|
|
image=None,
|
|
captchaType="normal",
|
|
),
|
|
),
|
|
_fake_request(),
|
|
)
|
|
)
|
|
|
|
for _ in range(20):
|
|
if len(attempts) >= 3:
|
|
break
|
|
time.sleep(0.01)
|
|
|
|
assert response["errorId"] == 0
|
|
assert response["status"] == "processing"
|
|
assert attempts == [
|
|
("https://example.com/callback", response["taskId"]),
|
|
("https://example.com/callback", response["taskId"]),
|
|
("https://example.com/callback", response["taskId"]),
|
|
]
|
|
|
|
|
|
def test_tasks_are_restored_from_disk():
|
|
app = _create_test_app()
|
|
create_task = _get_route(app, "/createTask")
|
|
get_task_result = _get_route(app, "/getTaskResult")
|
|
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
|
|
|
|
create_response = asyncio.run(
|
|
create_task(
|
|
SimpleNamespace(
|
|
clientKey="local",
|
|
task=SimpleNamespace(
|
|
type="ImageToTextTask",
|
|
body=encoded,
|
|
image=None,
|
|
captchaType="normal",
|
|
),
|
|
),
|
|
_fake_request(),
|
|
)
|
|
)
|
|
task_id = create_response["taskId"]
|
|
|
|
for _ in range(20):
|
|
result = asyncio.run(
|
|
get_task_result(SimpleNamespace(clientKey="local", taskId=task_id))
|
|
)
|
|
if result.get("status") == "ready":
|
|
break
|
|
time.sleep(0.01)
|
|
|
|
task_file = Path(SERVER_CONFIG["tasks_dir"]) / f"{task_id}.json"
|
|
assert task_file.exists()
|
|
|
|
reloaded_app = _create_test_app()
|
|
reloaded_get_task_result = _get_route(reloaded_app, "/getTaskResult")
|
|
reloaded_result = asyncio.run(
|
|
reloaded_get_task_result(SimpleNamespace(clientKey="local", taskId=task_id))
|
|
)
|
|
|
|
assert reloaded_result["status"] == "ready"
|
|
assert reloaded_result["solution"]["answer"] == "A3B8"
|
|
|
|
|
|
def test_callback_request_includes_signature_headers(monkeypatch):
|
|
monkeypatch.setitem(SERVER_CONFIG, "callback_signing_secret", "shared-secret")
|
|
|
|
request = server_module._build_callback_request(
|
|
"https://example.com/callback",
|
|
{"taskId": "abc", "status": "ready"},
|
|
)
|
|
|
|
body = urlencode({"taskId": "abc", "status": "ready"}).encode("utf-8")
|
|
headers = {key.lower(): value for key, value in request.header_items()}
|
|
timestamp = headers["x-captchabreaker-timestamp"]
|
|
signature = headers["x-captchabreaker-signature"]
|
|
|
|
assert headers["content-type"] == "application/x-www-form-urlencoded"
|
|
assert headers["x-captchabreaker-signature-alg"] == "hmac-sha256"
|
|
assert signature == server_module._sign_callback_payload(body, timestamp, "shared-secret")
|