Add Jin10 QR connection with cancellable login tasks
Store account credentials encrypted and bind QR tasks to administrator sessions. Report calendar access as awaiting validation after login. Prevent late polls and status responses from overwriting cancelled or newer operations, and clean up QR tasks when settings unmount. Cover authentication, cancellation races, and stale frontend responses.
This commit is contained in:
1 parent
5a991e3d29
commit
62d8c7eb65
14 files changed
+871
-1
No files matched your search
@@ -0,0 +1,227 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user