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,26 @@
|
|||||||
|
"""Add encrypted Jin10 QR connection; preserve existing calendar records."""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "d5e8fa132bc0"
|
||||||
|
down_revision = "c4d7e9f021ab"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table("jin10_connection",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("token_encrypted", sa.LargeBinary()),
|
||||||
|
sa.Column("connected_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("task_id", sa.String(64)),
|
||||||
|
sa.Column("owner", sa.String(64)),
|
||||||
|
sa.Column("qr_encrypted", sa.LargeBinary()),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("next_poll_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("error", sa.Text()))
|
||||||
|
op.execute("INSERT INTO jin10_connection (id) VALUES (1)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("jin10_connection")
|
||||||
@@ -19,6 +19,7 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.15.0",
|
"pydantic-settings>=2.15.0",
|
||||||
"pyjwt>=2.15.1",
|
"pyjwt>=2.15.1",
|
||||||
"pypdf>=6.21.2",
|
"pypdf>=6.21.2",
|
||||||
|
"qrcode[pil]>=8.2,<9",
|
||||||
"redis>=8.1.0",
|
"redis>=8.1.0",
|
||||||
"scrapy>=2.19.0",
|
"scrapy>=2.19.0",
|
||||||
"scrapy-redis>=0.9.1",
|
"scrapy-redis>=0.9.1",
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Administrator-only Jin10 connection; credentials never leave the backend."""
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import secrets
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from alphapulse.api.response import ApiResponse, success
|
||||||
|
from alphapulse.calendar import jin10_auth
|
||||||
|
from alphapulse.core.auth import AuthSession, check_session, require_admin
|
||||||
|
from alphapulse.core.crypto import decrypt, encrypt
|
||||||
|
from alphapulse.core.db import get_session
|
||||||
|
from alphapulse.models.jin10 import Jin10Connection
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/settings/jin10", tags=["settings"], dependencies=[Depends(require_admin)])
|
||||||
|
Session = Annotated[AsyncSession, Depends(get_session)]
|
||||||
|
Principal = Annotated[AuthSession, Depends(require_admin)]
|
||||||
|
|
||||||
|
|
||||||
|
async def connection(session: AsyncSession, *, lock: bool = False) -> Jin10Connection:
|
||||||
|
query = select(Jin10Connection).where(Jin10Connection.id == 1)
|
||||||
|
if lock:
|
||||||
|
query = query.with_for_update().execution_options(populate_existing=True)
|
||||||
|
row = await session.scalar(query)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(503, "金十连接尚未初始化,请先完成数据库迁移")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def clear_task(row: Jin10Connection) -> None:
|
||||||
|
row.task_id = row.owner = row.qr_encrypted = row.expires_at = row.next_poll_at = None
|
||||||
|
|
||||||
|
|
||||||
|
def public_status(row: Jin10Connection) -> dict:
|
||||||
|
return {"connected": row.token_encrypted is not None, "connected_at": row.connected_at,
|
||||||
|
"error": row.error, "sync_state": "awaiting_validation" if row.token_encrypted else "disconnected"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def status(session: Session) -> ApiResponse[dict]:
|
||||||
|
return success(public_status(await connection(session)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/qr", status_code=201)
|
||||||
|
async def create_qr(session: Session, principal: Principal) -> ApiResponse[dict]:
|
||||||
|
row = await connection(session, lock=True)
|
||||||
|
try:
|
||||||
|
token = await jin10_auth.generate_qr()
|
||||||
|
except jin10_auth.Jin10Error as exc:
|
||||||
|
raise HTTPException(502, str(exc)) from None
|
||||||
|
await check_session(principal)
|
||||||
|
row.task_id, row.owner = secrets.token_urlsafe(24), principal.jti
|
||||||
|
row.qr_encrypted = encrypt(token)
|
||||||
|
row.expires_at = datetime.now(UTC) + timedelta(seconds=60)
|
||||||
|
row.next_poll_at = datetime.now(UTC) + timedelta(seconds=5)
|
||||||
|
row.error = None
|
||||||
|
await session.commit()
|
||||||
|
return success({"task_id": row.task_id, "image": jin10_auth.qr_image(token), "expires_at": row.expires_at})
|
||||||
|
|
||||||
|
|
||||||
|
async def owned_task(session: AsyncSession, principal: AuthSession, task_id: str) -> Jin10Connection:
|
||||||
|
row = await connection(session, lock=True)
|
||||||
|
if row.task_id != task_id or row.owner != principal.jti:
|
||||||
|
raise HTTPException(404, "扫码任务不存在或已失效")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/qr/{task_id}/poll")
|
||||||
|
async def poll(task_id: str, session: Session, principal: Principal) -> ApiResponse[dict]:
|
||||||
|
row = await owned_task(session, principal, task_id)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if row.expires_at <= now:
|
||||||
|
clear_task(row)
|
||||||
|
await session.commit()
|
||||||
|
return success({"state": "expired"})
|
||||||
|
if row.next_poll_at > now:
|
||||||
|
return success({"state": "pending"})
|
||||||
|
token = decrypt(row.qr_encrypted)
|
||||||
|
# Claim the remaining QR lifetime, then release the lock before network I/O.
|
||||||
|
# A failed request can be retried with a new QR; overlapping polls cannot log in twice.
|
||||||
|
row.next_poll_at = row.expires_at
|
||||||
|
await session.commit()
|
||||||
|
error = None
|
||||||
|
try:
|
||||||
|
state, credential = await jin10_auth.poll_qr(token)
|
||||||
|
except jin10_auth.Jin10Error as exc:
|
||||||
|
error = str(exc)
|
||||||
|
# Cancellation, replacement and disconnection must invalidate both success and failure.
|
||||||
|
row = await owned_task(session, principal, task_id)
|
||||||
|
await check_session(principal)
|
||||||
|
# Recheck expiry after network I/O, including user confirmation latency.
|
||||||
|
if datetime.now(UTC) >= row.expires_at:
|
||||||
|
state = "expired"
|
||||||
|
elif error is not None:
|
||||||
|
row.error = error
|
||||||
|
clear_task(row)
|
||||||
|
await session.commit()
|
||||||
|
raise HTTPException(502, error)
|
||||||
|
if state == "connected":
|
||||||
|
row.token_encrypted = encrypt(credential)
|
||||||
|
row.connected_at = datetime.now(UTC)
|
||||||
|
row.error = None
|
||||||
|
if state != "pending":
|
||||||
|
clear_task(row)
|
||||||
|
else:
|
||||||
|
row.next_poll_at = datetime.now(UTC) + timedelta(seconds=5)
|
||||||
|
await session.commit()
|
||||||
|
return success({"state": state})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/qr/{task_id}")
|
||||||
|
async def cancel(task_id: str, session: Session, principal: Principal) -> ApiResponse[None]:
|
||||||
|
row = await owned_task(session, principal, task_id)
|
||||||
|
await check_session(principal)
|
||||||
|
clear_task(row)
|
||||||
|
await session.commit()
|
||||||
|
return success(None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("")
|
||||||
|
async def disconnect(session: Session, principal: Principal) -> ApiResponse[None]:
|
||||||
|
row = await connection(session, lock=True)
|
||||||
|
await check_session(principal)
|
||||||
|
clear_task(row)
|
||||||
|
row.token_encrypted = row.connected_at = row.error = None
|
||||||
|
await session.commit()
|
||||||
|
return success(None)
|
||||||
@@ -5,7 +5,7 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from alphapulse.api import auth, calendar, chat, crawler, market, news, settings, skills, trading, risk
|
from alphapulse.api import auth, calendar, chat, crawler, market, news, settings, skills, trading, risk, jin10
|
||||||
from alphapulse.api.response import (
|
from alphapulse.api.response import (
|
||||||
ApiResponse, ValidationErrors, http_error, internal_error, success, validation_error,
|
ApiResponse, ValidationErrors, http_error, internal_error, success, validation_error,
|
||||||
)
|
)
|
||||||
@@ -30,6 +30,7 @@ app.include_router(chat.router)
|
|||||||
app.include_router(trading.router)
|
app.include_router(trading.router)
|
||||||
app.include_router(calendar.router)
|
app.include_router(calendar.router)
|
||||||
app.include_router(risk.router)
|
app.include_router(risk.router)
|
||||||
|
app.include_router(jin10.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health", include_in_schema=False)
|
@app.get("/api/health", include_in_schema=False)
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Use the QR endpoints published by Jin10's current login component."""
|
||||||
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import qrcode
|
||||||
|
|
||||||
|
UC_URL = "https://uc-api.jin10.com"
|
||||||
|
HEADERS = {"User-Agent": "Mozilla/5.0", "Referer": "https://rili.jin10.com/",
|
||||||
|
"x-app-id": "YDcStOzjliuTJUvh", "x-version": "1.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
class Jin10Error(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def uc_request(path: str, params: dict | None = None, token: str | None = None) -> dict:
|
||||||
|
headers = {**HEADERS, **({"x-token": token} if token else {})}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10, trust_env=False) as client:
|
||||||
|
response = await client.get(UC_URL + path, params=params, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.json()
|
||||||
|
if not isinstance(body, dict) or body.get("status") != 200:
|
||||||
|
raise Jin10Error("金十未接受请求,请重新扫码或稍后重试")
|
||||||
|
return body
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
# Upstream messages and request URLs may contain credentials.
|
||||||
|
raise Jin10Error("金十连接失败,请稍后重试") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_qr() -> str:
|
||||||
|
body = await uc_request("/qrcode/generate", {"device_type": "web"})
|
||||||
|
value = body.get("data")
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value = value.get("token")
|
||||||
|
if not isinstance(value, str) or not value or len(value) > 2048:
|
||||||
|
raise Jin10Error("金十二维码响应格式已变化")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def qr_image(token: str) -> str:
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
image = qrcode.make("https://www.jin10.com/?" + urlencode({"action": "login", "tag": token}))
|
||||||
|
stream = BytesIO()
|
||||||
|
image.save(stream, format="PNG")
|
||||||
|
return "data:image/png;base64," + base64.b64encode(stream.getvalue()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
async def poll_qr(token: str) -> tuple[str, str | None]:
|
||||||
|
body = await uc_request("/qrcode/try_login", {"token": token})
|
||||||
|
data = body.get("data")
|
||||||
|
if data is None:
|
||||||
|
return "pending", None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise Jin10Error("金十扫码状态响应格式已变化")
|
||||||
|
credential = data.get("token")
|
||||||
|
if isinstance(credential, str) and credential:
|
||||||
|
# Confirm that a QR success really yields an authenticated account.
|
||||||
|
info = await uc_request("/userinfo", token=credential)
|
||||||
|
if not isinstance(info.get("data"), dict) or not info["data"]:
|
||||||
|
raise Jin10Error("金十登录态验证失败")
|
||||||
|
return "connected", credential
|
||||||
|
if str(data.get("qr_status", "")).lower() in ("expired", "invalid", "timeout"):
|
||||||
|
return "expired", None
|
||||||
|
return "pending", None
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from alphapulse.models.jin10 import Jin10Connection
|
||||||
from alphapulse.models.risk import RiskSettings, RiskEvent, RiskAnalysis, RiskSnapshot
|
from alphapulse.models.risk import RiskSettings, RiskEvent, RiskAnalysis, RiskSnapshot
|
||||||
from alphapulse.core.db import Base
|
from alphapulse.core.db import Base
|
||||||
from alphapulse.models.ai import AiProvider, ChatMessage, ChatSession, ContextVersion, SkillNode, SkillVersion
|
from alphapulse.models.ai import AiProvider, ChatMessage, ChatSession, ContextVersion, SkillNode, SkillVersion
|
||||||
@@ -7,6 +8,7 @@ from alphapulse.models.news import CrawlLog, CrawlRun, NewsItem, NewsRevision, R
|
|||||||
from alphapulse.models.trading import NewsSignal, TradingAccount, TradingAnalysis, TradingCredential, TradingEvent, TradingFill, TradingOrder, TradingSymbol
|
from alphapulse.models.trading import NewsSignal, TradingAccount, TradingAnalysis, TradingCredential, TradingEvent, TradingFill, TradingOrder, TradingSymbol
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"Jin10Connection",
|
||||||
"RiskSettings", "RiskEvent", "RiskAnalysis", "RiskSnapshot",
|
"RiskSettings", "RiskEvent", "RiskAnalysis", "RiskSnapshot",
|
||||||
"AiProvider",
|
"AiProvider",
|
||||||
"Base",
|
"Base",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Encrypted connection and short-lived, session-owned QR login state."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, LargeBinary, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from alphapulse.core.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Jin10Connection(Base):
|
||||||
|
__tablename__ = "jin10_connection"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
token_encrypted: Mapped[bytes | None] = mapped_column(LargeBinary)
|
||||||
|
connected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
task_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
owner: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
qr_encrypted: Mapped[bytes | None] = mapped_column(LargeBinary)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -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)
|
||||||
Generated
+19
@@ -181,6 +181,7 @@ dependencies = [
|
|||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "pypdf" },
|
{ name = "pypdf" },
|
||||||
|
{ name = "qrcode", extra = ["pil"] },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "scrapy" },
|
{ name = "scrapy" },
|
||||||
{ name = "scrapy-redis" },
|
{ name = "scrapy-redis" },
|
||||||
@@ -207,6 +208,7 @@ requires-dist = [
|
|||||||
{ name = "pydantic-settings", specifier = ">=2.15.0" },
|
{ name = "pydantic-settings", specifier = ">=2.15.0" },
|
||||||
{ name = "pyjwt", specifier = ">=2.15.1" },
|
{ name = "pyjwt", specifier = ">=2.15.1" },
|
||||||
{ name = "pypdf", specifier = ">=6.21.2" },
|
{ name = "pypdf", specifier = ">=6.21.2" },
|
||||||
|
{ name = "qrcode", extras = ["pil"], specifier = ">=8.2,<9" },
|
||||||
{ name = "redis", specifier = ">=8.1.0" },
|
{ name = "redis", specifier = ">=8.1.0" },
|
||||||
{ name = "scrapy", specifier = ">=2.19.0" },
|
{ name = "scrapy", specifier = ">=2.19.0" },
|
||||||
{ name = "scrapy-redis", specifier = ">=0.9.1" },
|
{ name = "scrapy-redis", specifier = ">=0.9.1" },
|
||||||
@@ -2036,6 +2038,23 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "8.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
pil = [
|
||||||
|
{ name = "pillow" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "queuelib"
|
name = "queuelib"
|
||||||
version = "1.10.0"
|
version = "1.10.0"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type * as Jin10 from "./type"
|
||||||
|
import { request } from "@/http/axios"
|
||||||
|
|
||||||
|
export function getJin10Api() {
|
||||||
|
return request<Jin10.Jin10Status>({ url: "/settings/jin10", method: "get" })
|
||||||
|
}
|
||||||
|
export function createJin10QrApi() {
|
||||||
|
return request<Jin10.Jin10Qr>({ url: "/settings/jin10/qr", method: "post" })
|
||||||
|
}
|
||||||
|
export function pollJin10QrApi(id: string) {
|
||||||
|
return request<Jin10.Jin10Poll>({ url: `/settings/jin10/qr/${id}/poll`, method: "post" })
|
||||||
|
}
|
||||||
|
export function cancelJin10QrApi(id: string) {
|
||||||
|
return request<null>({ url: `/settings/jin10/qr/${id}`, method: "delete", notify: false })
|
||||||
|
}
|
||||||
|
export function disconnectJin10Api() {
|
||||||
|
return request<null>({ url: "/settings/jin10", method: "delete" })
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export interface Jin10Status {
|
||||||
|
connected: boolean
|
||||||
|
connected_at: string | null
|
||||||
|
error: string | null
|
||||||
|
sync_state: "awaiting_validation" | "disconnected"
|
||||||
|
}
|
||||||
|
export interface Jin10Qr {
|
||||||
|
task_id: string
|
||||||
|
image: string
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
export interface Jin10Poll {
|
||||||
|
state: "pending" | "connected" | "expired"
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Jin10Qr, Jin10Status } from "@@/apis/jin10/type"
|
||||||
|
import { cancelJin10QrApi, createJin10QrApi, disconnectJin10Api, getJin10Api, pollJin10QrApi } from "@@/apis/jin10"
|
||||||
|
import { useSessionScope } from "@@/composables/useSessionScope"
|
||||||
|
import { useUserStore } from "@/pinia/stores/user"
|
||||||
|
|
||||||
|
const scope = useSessionScope()
|
||||||
|
const user = useUserStore()
|
||||||
|
const sessionVersion = user.sessionVersion
|
||||||
|
const sameSession = () => sessionVersion === user.sessionVersion
|
||||||
|
const status = ref<Jin10Status>()
|
||||||
|
const qr = ref<Jin10Qr>()
|
||||||
|
const visible = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const state = ref("idle")
|
||||||
|
const failed = ref(false)
|
||||||
|
let generation = 0
|
||||||
|
let statusRequest = 0
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const request = ++statusRequest
|
||||||
|
const current = generation
|
||||||
|
try {
|
||||||
|
const result = await getJin10Api()
|
||||||
|
if (scope.isCurrent() && request === statusRequest && current === generation) {
|
||||||
|
status.value = result
|
||||||
|
failed.value = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (scope.isCurrent() && request === statusRequest && current === generation) failed.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function stop() {
|
||||||
|
++generation
|
||||||
|
clearTimeout(timer)
|
||||||
|
const id = qr.value?.task_id
|
||||||
|
qr.value = undefined
|
||||||
|
if (id && sameSession()) void cancelJin10QrApi(id).catch(() => {})
|
||||||
|
}
|
||||||
|
async function poll(id: string, current: number) {
|
||||||
|
if (!scope.isCurrent() || current !== generation || !visible.value) return
|
||||||
|
try {
|
||||||
|
const result = await pollJin10QrApi(id)
|
||||||
|
if (!scope.isCurrent() || current !== generation || !visible.value) return
|
||||||
|
state.value = result.state
|
||||||
|
if (result.state === "pending") {
|
||||||
|
timer = setTimeout(poll, 5000, id, current)
|
||||||
|
} else {
|
||||||
|
qr.value = undefined
|
||||||
|
if (result.state === "connected") {
|
||||||
|
visible.value = false
|
||||||
|
const completed = generation
|
||||||
|
await load()
|
||||||
|
if (scope.isCurrent() && completed === generation) ElMessage.success("金十账号已连接,日历读取权限待核验")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (scope.isCurrent() && current === generation) state.value = "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function start() {
|
||||||
|
if (busy.value) return
|
||||||
|
stop()
|
||||||
|
const current = generation
|
||||||
|
visible.value = true
|
||||||
|
busy.value = true
|
||||||
|
state.value = "loading"
|
||||||
|
try {
|
||||||
|
const result = await createJin10QrApi()
|
||||||
|
if (!scope.isCurrent() || current !== generation || !visible.value) {
|
||||||
|
if (sameSession()) await cancelJin10QrApi(result.task_id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qr.value = result
|
||||||
|
state.value = "pending"
|
||||||
|
timer = setTimeout(poll, 5000, result.task_id, current)
|
||||||
|
} catch {
|
||||||
|
if (scope.isCurrent() && current === generation) state.value = "error"
|
||||||
|
} finally {
|
||||||
|
if (scope.isCurrent()) busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function disconnect() {
|
||||||
|
if (busy.value) return
|
||||||
|
const confirmed = generation
|
||||||
|
await ElMessageBox.confirm("断开本项目的金十连接?已保存的日历历史会保留。", "断开连接")
|
||||||
|
scope.assertCurrent()
|
||||||
|
if (confirmed !== generation || busy.value) return
|
||||||
|
stop()
|
||||||
|
const current = generation
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await disconnectJin10Api()
|
||||||
|
if (scope.isCurrent() && current === generation) await load()
|
||||||
|
} finally {
|
||||||
|
if (scope.isCurrent() && current === generation) busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
onBeforeUnmount(stop)
|
||||||
|
watch(visible, (value) => {
|
||||||
|
if (!value) stop()
|
||||||
|
}, { flush: "sync" })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
金十日历
|
||||||
|
</template>
|
||||||
|
<el-alert v-if="failed" title="连接状态读取失败" type="error" :closable="false" />
|
||||||
|
<template v-else-if="status">
|
||||||
|
<p>
|
||||||
|
<el-tag :type="status.connected ? 'success' : 'info'">
|
||||||
|
{{ status.connected ? "账号已连接" : "尚未连接" }}
|
||||||
|
</el-tag>
|
||||||
|
</p>
|
||||||
|
<p>全球宏观数据、重要事件和假期。官方同口径数据优先,金十补充缺项。</p>
|
||||||
|
<p v-if="status.connected">
|
||||||
|
日历读取权限待核验;账号连接成功不代表日历同步已就绪。
|
||||||
|
</p>
|
||||||
|
<el-alert v-if="status.error" :title="status.error" type="warning" :closable="false" />
|
||||||
|
<el-button type="primary" :loading="busy" @click="start">
|
||||||
|
{{ status.connected ? "重新扫码" : "扫码连接金十" }}
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="status.connected" @click="disconnect">
|
||||||
|
断开连接
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<el-button v-if="failed" @click="load">
|
||||||
|
重试
|
||||||
|
</el-button>
|
||||||
|
<el-dialog v-model="visible" title="使用金十 App 扫码登录" width="min(420px, 95vw)" :close-on-click-modal="false">
|
||||||
|
<div v-loading="busy" class="jin10-qr">
|
||||||
|
<img v-if="qr && state === 'pending'" :src="qr.image" alt="金十登录二维码" width="240" height="240">
|
||||||
|
<p v-if="state === 'pending'">
|
||||||
|
请用金十 App 扫描并确认,二维码 60 秒后失效。
|
||||||
|
</p>
|
||||||
|
<p v-else-if="state === 'expired'">
|
||||||
|
二维码已失效,请刷新后重新扫码。
|
||||||
|
</p>
|
||||||
|
<p v-else-if="state === 'error'">
|
||||||
|
连接失败,请稍后重试。
|
||||||
|
</p>
|
||||||
|
<el-button v-if="state === 'expired' || state === 'error'" @click="start">
|
||||||
|
刷新二维码
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jin10-qr {
|
||||||
|
min-height: 260px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.jin10-qr img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ExchangeSettings from "./components/ExchangeSettings.vue"
|
import ExchangeSettings from "./components/ExchangeSettings.vue"
|
||||||
|
import Jin10Settings from "./components/Jin10Settings.vue"
|
||||||
import ProviderSettings from "./components/ProviderSettings.vue"
|
import ProviderSettings from "./components/ProviderSettings.vue"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<ProviderSettings />
|
<ProviderSettings />
|
||||||
|
<Jin10Settings />
|
||||||
<ExchangeSettings />
|
<ExchangeSettings />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import * as vue from "vue"
|
||||||
|
import { moduleLoader } from "./module-loader.ts"
|
||||||
|
|
||||||
|
function setup(overrides: Record<string, unknown> = {}) {
|
||||||
|
const user = { sessionVersion: 1 }
|
||||||
|
const unmountHooks: (() => void)[] = []
|
||||||
|
const onBeforeUnmount = (hook: () => void) => unmountHooks.push(hook)
|
||||||
|
const cancelled: string[] = []
|
||||||
|
const messages: string[] = []
|
||||||
|
const timers: (() => Promise<void>)[] = []
|
||||||
|
const load = moduleLoader({
|
||||||
|
"vue": { ...vue, onBeforeUnmount },
|
||||||
|
"@/pinia/stores/user": { useUserStore: () => user },
|
||||||
|
"@@/apis/jin10": {
|
||||||
|
createJin10QrApi: async () => ({ task_id: "a", image: "png", expires_at: "later" }),
|
||||||
|
pollJin10QrApi: async () => ({ state: "connected" }),
|
||||||
|
cancelJin10QrApi: async (id: string) => cancelled.push(id),
|
||||||
|
getJin10Api: async () => ({ connected: true, sync_state: "awaiting_validation" }),
|
||||||
|
disconnectJin10Api: async () => {},
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
...vue,
|
||||||
|
onMounted() {},
|
||||||
|
onBeforeUnmount,
|
||||||
|
setTimeout: (fn: (...args: unknown[]) => Promise<void>, _delay: number, ...args: unknown[]) => {
|
||||||
|
timers.push(() => fn(...args))
|
||||||
|
return timers.length
|
||||||
|
},
|
||||||
|
clearTimeout() {},
|
||||||
|
ElMessage: { success: (message: string) => messages.push(message) },
|
||||||
|
ElMessageBox: { confirm: async () => {} }
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
page: load("@/pages/settings/components/Jin10Settings.vue").default.setup({}, { expose() {} }),
|
||||||
|
cancelled,
|
||||||
|
messages,
|
||||||
|
timers,
|
||||||
|
unmount: () => unmountHooks.forEach(hook => hook()),
|
||||||
|
invalidate: () => {
|
||||||
|
user.sessionVersion++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("QR generation is serialized and closing cancels a late response", async () => {
|
||||||
|
const pending = Promise.withResolvers<any>()
|
||||||
|
let creates = 0
|
||||||
|
const env = setup({ createJin10QrApi: () => {
|
||||||
|
creates++
|
||||||
|
return pending.promise
|
||||||
|
} })
|
||||||
|
const first = env.page.start()
|
||||||
|
await env.page.start()
|
||||||
|
assert.equal(creates, 1)
|
||||||
|
env.page.visible.value = false
|
||||||
|
await vue.nextTick()
|
||||||
|
pending.resolve({ task_id: "old", image: "png", expires_at: "later" })
|
||||||
|
await first
|
||||||
|
assert.equal(env.page.qr.value, undefined)
|
||||||
|
assert.deepEqual(env.cancelled, ["old"])
|
||||||
|
assert.equal(env.page.busy.value, false)
|
||||||
|
assert.equal(env.timers.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a closed QR dialog ignores a late successful poll", async () => {
|
||||||
|
const pending = Promise.withResolvers<any>()
|
||||||
|
const env = setup({ pollJin10QrApi: () => pending.promise })
|
||||||
|
await env.page.start()
|
||||||
|
const polling = env.timers[0]()
|
||||||
|
env.page.visible.value = false
|
||||||
|
await vue.nextTick()
|
||||||
|
pending.resolve({ state: "connected" })
|
||||||
|
await polling
|
||||||
|
assert.deepEqual(env.messages, [])
|
||||||
|
assert.equal(env.page.status.value, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("successful login reports connection without claiming calendar sync", async () => {
|
||||||
|
const env = setup()
|
||||||
|
await env.page.start()
|
||||||
|
await env.timers[0]()
|
||||||
|
assert.equal(env.page.visible.value, false)
|
||||||
|
assert.equal(env.page.status.value.sync_state, "awaiting_validation")
|
||||||
|
assert.match(env.messages[0], /待核验/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("session changes discard QR responses and never cancel under a new identity", async () => {
|
||||||
|
const pending = Promise.withResolvers<any>()
|
||||||
|
const env = setup({ createJin10QrApi: () => pending.promise })
|
||||||
|
const first = env.page.start()
|
||||||
|
env.invalidate()
|
||||||
|
pending.resolve({ task_id: "old-session", image: "png", expires_at: "later" })
|
||||||
|
await first
|
||||||
|
assert.equal(env.page.qr.value, undefined)
|
||||||
|
assert.deepEqual(env.cancelled, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unmount cancels the QR after the real session scope has disposed", async () => {
|
||||||
|
const env = setup()
|
||||||
|
await env.page.start()
|
||||||
|
env.unmount()
|
||||||
|
await env.timers[0]()
|
||||||
|
assert.deepEqual(env.cancelled, ["a"])
|
||||||
|
assert.equal(env.page.qr.value, undefined)
|
||||||
|
assert.deepEqual(env.messages, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a QR created after unmount is cancelled without changing the disposed page", async () => {
|
||||||
|
const pending = Promise.withResolvers<any>()
|
||||||
|
const env = setup({ createJin10QrApi: () => pending.promise })
|
||||||
|
const starting = env.page.start()
|
||||||
|
env.unmount()
|
||||||
|
pending.resolve({ task_id: "late", image: "png", expires_at: "later" })
|
||||||
|
await starting
|
||||||
|
assert.deepEqual(env.cancelled, ["late"])
|
||||||
|
assert.equal(env.page.qr.value, undefined)
|
||||||
|
assert.equal(env.timers.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unmount never cancels a task under a changed identity", async () => {
|
||||||
|
const env = setup()
|
||||||
|
await env.page.start()
|
||||||
|
env.invalidate()
|
||||||
|
env.unmount()
|
||||||
|
assert.deepEqual(env.cancelled, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("an older status response cannot overwrite a newer response", async () => {
|
||||||
|
const older = Promise.withResolvers<any>()
|
||||||
|
let requests = 0
|
||||||
|
const env = setup({ getJin10Api: () => ++requests === 1 ? older.promise : Promise.resolve({ connected: false }) })
|
||||||
|
const loading = env.page.load()
|
||||||
|
await env.page.load()
|
||||||
|
older.resolve({ connected: true })
|
||||||
|
await loading
|
||||||
|
assert.equal(env.page.status.value.connected, false)
|
||||||
|
assert.equal(env.page.failed.value, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("an older status error cannot replace a newer successful response", async () => {
|
||||||
|
const older = Promise.withResolvers<any>()
|
||||||
|
let requests = 0
|
||||||
|
const env = setup({ getJin10Api: () => ++requests === 1 ? older.promise : Promise.resolve({ connected: false }) })
|
||||||
|
const loading = env.page.load()
|
||||||
|
await env.page.load()
|
||||||
|
older.reject(new Error("old request failed"))
|
||||||
|
await loading
|
||||||
|
assert.equal(env.page.status.value.connected, false)
|
||||||
|
assert.equal(env.page.failed.value, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("disconnect invalidates status requests before its API response", async () => {
|
||||||
|
const oldStatus = Promise.withResolvers<any>()
|
||||||
|
const removing = Promise.withResolvers<void>()
|
||||||
|
let requests = 0
|
||||||
|
const env = setup({
|
||||||
|
getJin10Api: () => ++requests === 1 ? oldStatus.promise : Promise.resolve({ connected: false }),
|
||||||
|
disconnectJin10Api: () => removing.promise
|
||||||
|
})
|
||||||
|
const loading = env.page.load()
|
||||||
|
const disconnecting = env.page.disconnect()
|
||||||
|
await vue.nextTick()
|
||||||
|
oldStatus.resolve({ connected: true })
|
||||||
|
await loading
|
||||||
|
assert.equal(env.page.status.value, undefined)
|
||||||
|
removing.resolve()
|
||||||
|
await disconnecting
|
||||||
|
assert.equal(env.page.status.value.connected, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a stale disconnect confirmation cannot cancel a newer QR", async () => {
|
||||||
|
const env = setup()
|
||||||
|
const disconnecting = env.page.disconnect()
|
||||||
|
await env.page.start()
|
||||||
|
await disconnecting
|
||||||
|
assert.equal(env.page.qr.value.task_id, "a")
|
||||||
|
assert.deepEqual(env.cancelled, [])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user