Files
AlphaPulse/backend/tests/test_jin10.py
T

256 lines
11 KiB
Python

"""QR tests isolate all third-party, database and session I/O."""
import asyncio
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
@pytest.fixture
def qr_env(trading, monkeypatch):
from alphapulse.api import jin10
from alphapulse.calendar import jin10_auth
from alphapulse.core.auth import AuthSession
from alphapulse.core.crypto import encrypt
from fastapi import FastAPI
from alphapulse.core.db import get_session
from alphapulse.core.auth import require_admin
row = SimpleNamespace(id=1, token_encrypted=None, connected_at=None, task_id=None,
owner=None, qr_encrypted=None, expires_at=None, next_poll_at=None, error=None)
session = SimpleNamespace(scalar=AsyncMock(return_value=row), commit=AsyncMock())
principal = AuthSession("test", "session-a", 9999999999)
app = FastAPI()
app.include_router(jin10.router)
app.dependency_overrides[get_session] = lambda: session
monkeypatch.setattr(jin10, "check_session", AsyncMock())
monkeypatch.setattr(jin10_auth, "generate_qr", AsyncMock(return_value="private-qr-token"))
monkeypatch.setattr(jin10_auth, "poll_qr", AsyncMock(return_value=("pending", None)))
def authorize():
app.dependency_overrides[require_admin] = lambda: principal
def pending():
row.task_id, row.owner = "task-a", principal.jti
row.qr_encrypted = encrypt("private-qr-token")
row.expires_at = datetime.now(UTC) + timedelta(seconds=60)
row.next_poll_at = datetime.now(UTC) - timedelta(seconds=1)
return SimpleNamespace(api=jin10, upstream=jin10_auth, app=app, row=row, session=session,
principal=principal, authorize=authorize, pending=pending)
def request(env, method, path="", **kwargs):
async def run():
async with httpx.AsyncClient(transport=httpx.ASGITransport(env.app), base_url="http://test") as client:
return await client.request(method, "/api/settings/jin10" + path, **kwargs)
return asyncio.run(run())
@pytest.mark.parametrize("method,path", [("GET", ""), ("POST", "/qr"), ("POST", "/qr/task-a/poll"),
("DELETE", "/qr/task-a"), ("DELETE", "")])
def test_auth_required(qr_env, method, path):
assert request(qr_env, method, path).status_code == 401
qr_env.session.scalar.assert_not_awaited()
def test_create_hides_credentials_and_invalidates_old_task(qr_env):
from alphapulse.core.crypto import decrypt
e = qr_env
e.authorize()
e.pending()
response = request(e, "POST", "/qr")
assert response.status_code == 201
body = response.json()["data"]
assert body["image"].startswith("data:image/png;base64,")
assert "private-qr-token" not in response.text
assert e.row.task_id != "task-a"
assert decrypt(e.row.qr_encrypted) == "private-qr-token"
assert e.row.owner == e.principal.jti
assert request(e, "POST", "/qr/task-a/poll").status_code == 404
@pytest.mark.parametrize("owner,expired", [("different-session", False), ("session-a", True)])
def test_owner_and_expiry_block_upstream(qr_env, owner, expired):
e = qr_env
e.authorize()
e.pending()
e.row.owner = owner
if expired:
e.row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
response = request(e, "POST", "/qr/task-a/poll")
assert response.status_code == (200 if expired else 404)
if expired:
assert response.json()["data"]["state"] == "expired"
assert e.row.qr_encrypted is None
e.upstream.poll_qr.assert_not_awaited()
def test_poll_throttles_and_stores_success_encrypted(qr_env):
from alphapulse.core.crypto import decrypt
e = qr_env
e.authorize()
e.pending()
e.row.next_poll_at = datetime.now(UTC) + timedelta(seconds=5)
assert request(e, "POST", "/qr/task-a/poll").json()["data"]["state"] == "pending"
e.upstream.poll_qr.assert_not_awaited()
e.row.next_poll_at = datetime.now(UTC) - timedelta(seconds=1)
e.upstream.poll_qr.return_value = "connected", "private-account-token"
response = request(e, "POST", "/qr/task-a/poll")
assert response.json()["data"]["state"] == "connected"
assert "private-account-token" not in response.text
assert decrypt(e.row.token_encrypted) == "private-account-token"
assert e.row.qr_encrypted is None
status = request(e, "GET")
assert status.json()["data"]["sync_state"] == "awaiting_validation"
assert "private-account-token" not in status.text
@pytest.mark.parametrize("failure", [False, True])
def test_revoke_during_upstream_cannot_save_result(qr_env, failure):
from fastapi import HTTPException
e = qr_env
e.authorize()
e.pending()
e.upstream.poll_qr.return_value = "connected", "private-account-token"
if failure:
e.upstream.poll_qr.side_effect = e.upstream.Jin10Error("upstream failed")
e.api.check_session.side_effect = HTTPException(401, "expired")
assert request(e, "POST", "/qr/task-a/poll").status_code == 401
assert e.row.token_encrypted is None
assert e.row.error is None
assert e.session.commit.await_count == 1 # Only the poll claim was committed.
@pytest.mark.parametrize("action", ["cancel", "replace", "disconnect"])
@pytest.mark.parametrize("failure", [False, True])
def test_invalidated_poll_cannot_change_connection(qr_env, action, failure):
e = qr_env
e.authorize()
e.pending()
async def run():
entered, release = asyncio.Event(), asyncio.Event()
async def upstream(token):
entered.set()
await release.wait()
if failure:
raise e.upstream.Jin10Error("upstream failed")
return "connected", "private-account-token"
e.upstream.poll_qr.side_effect = upstream
async with httpx.AsyncClient(transport=httpx.ASGITransport(e.app), base_url="http://test") as client:
polling = asyncio.create_task(client.post("/api/settings/jin10/qr/task-a/poll"))
await asyncio.wait_for(entered.wait(), 1)
try:
if action == "replace":
response = await client.post("/api/settings/jin10/qr")
assert response.status_code == 201
replacement = response.json()["data"]["task_id"]
else:
path = "/qr/task-a" if action == "cancel" else ""
response = await client.delete("/api/settings/jin10" + path)
assert response.status_code == 200
commits = e.session.commit.await_count
finally:
release.set()
assert (await polling).status_code == 404
assert e.session.commit.await_count == commits
assert e.row.token_encrypted is None
assert e.row.error is None
assert e.row.task_id == (replacement if action == "replace" else None)
asyncio.run(run())
def test_overlapping_polls_only_contact_upstream_once(qr_env):
e = qr_env
e.authorize()
e.pending()
async def run():
entered, release = asyncio.Event(), asyncio.Event()
async def upstream(token):
entered.set()
await release.wait()
return "pending", None
e.upstream.poll_qr.side_effect = upstream
async with httpx.AsyncClient(transport=httpx.ASGITransport(e.app), base_url="http://test") as client:
first = asyncio.create_task(client.post("/api/settings/jin10/qr/task-a/poll"))
await asyncio.wait_for(entered.wait(), 1)
try:
duplicate = await client.post("/api/settings/jin10/qr/task-a/poll")
assert duplicate.json()["data"]["state"] == "pending"
assert e.upstream.poll_qr.await_count == 1
finally:
release.set()
assert (await first).json()["data"]["state"] == "pending"
e.upstream.poll_qr.return_value = "connected", "private-account-token"
e.upstream.poll_qr.side_effect = None
e.row.next_poll_at = datetime.now(UTC) - timedelta(seconds=1)
assert (await client.post("/api/settings/jin10/qr/task-a/poll")).json()["data"]["state"] == "connected"
asyncio.run(run())
def test_disconnect_and_cancel_do_not_contact_remote_logout(qr_env):
e = qr_env
e.authorize()
e.pending()
assert request(e, "DELETE", "/qr/task-a").status_code == 200
assert e.row.qr_encrypted is None
e.row.token_encrypted = b"encrypted"
assert request(e, "DELETE").status_code == 200
assert e.row.token_encrypted is None
e.upstream.poll_qr.assert_not_awaited()
@pytest.mark.parametrize("data,state", [(None, "pending"), ({}, "pending"), ({"qr_status": "timeout"}, "expired")])
def test_sdk_pending_and_expiry_responses(qr_env, monkeypatch, data, state):
from alphapulse.calendar import jin10_auth
# Restore the real parser, while keeping HTTP isolated.
import importlib
jin10_auth = importlib.reload(jin10_auth)
monkeypatch.setattr(jin10_auth, "uc_request", AsyncMock(return_value={"status": 200, "data": data}))
assert asyncio.run(jin10_auth.poll_qr("private")) == (state, None)
def test_upstream_errors_never_expose_tokens(qr_env, monkeypatch):
from alphapulse.calendar import jin10_auth
import importlib
jin10_auth = importlib.reload(jin10_auth)
mock = AsyncMock(side_effect=httpx.ConnectError("private-token-in-url"))
monkeypatch.setattr(httpx.AsyncClient, "get", mock)
with pytest.raises(jin10_auth.Jin10Error, match="金十连接失败") as exc:
asyncio.run(jin10_auth.uc_request("/qrcode/try_login", {"token": "private"}))
assert "private" not in str(exc.value)
@pytest.mark.parametrize("message,expired", [("二维码已过期", True), ("请求被拒绝", False)])
def test_observed_upstream_expiry_is_not_a_generic_connection_failure(qr_env, monkeypatch, message, expired):
from alphapulse.calendar import jin10_auth
import importlib
jin10_auth = importlib.reload(jin10_auth)
response = httpx.Response(200, json={"status": 400, "message": message}, request=httpx.Request("GET", "https://uc-api.jin10.com/qrcode/try_login"))
monkeypatch.setattr(httpx.AsyncClient, "get", AsyncMock(return_value=response))
if expired:
assert asyncio.run(jin10_auth.poll_qr("private")) == ("expired", None)
else:
with pytest.raises(jin10_auth.Jin10Error, match="未接受请求"):
asyncio.run(jin10_auth.poll_qr("private"))
def test_upstream_expiry_clears_task_without_disconnecting_existing_account(qr_env):
e = qr_env
e.authorize()
e.pending()
e.row.token_encrypted = b"existing-encrypted-credential"
e.upstream.poll_qr.return_value = "expired", None
response = request(e, "POST", "/qr/task-a/poll")
assert response.status_code == 200
assert response.json()["data"]["state"] == "expired"
assert e.row.token_encrypted == b"existing-encrypted-credential"
assert e.row.qr_encrypted is None
assert e.row.error is None