"""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)