feat: session login + authed client
Task 1.4 — 会话层. Adds the authenticated HTTP client that every yunke scraper will use to call the CRM API. - app/session/login.py: get_secure_key + pc_login wrappers, LoginResult dataclass, and a clearly-marked Phase-0 swappable encrypt_password placeholder (real RSA per getSecureKey is a TODO; drop-in contract documented in the docstring). - app/session/yunke_client.py: YunkeClient wrapping httpx.AsyncClient. Constructor takes either (username+password) for auto-login or (userid+company[+cookies]) for an existing session. Injects the 5 observed auth headers (userid/company/source=yunkepc/channel=4/ content-type=application/json) on every request, auto-logs-in on first request, and on HTTP 401 (or body code 401) re-logs-in once and retries — a persistent 401 raises after the single retry so there is no infinite loop. - Tests (respx): login populates LoginResult + httpx cookie jar; every request carries all 5 auth headers + cookie; 401 triggers exactly one re-login + retry and returns 200; persistent 401 raises after one retry; first request with no session auto-logs-in. Session is held in-memory only for now; persistence to encrypted YkAccount.session_blob_enc is left as a later concern (TODO in yunke_client.py). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
"""Tests for the yunke session layer: ``app.session.login`` + ``YunkeClient``.
|
||||
|
||||
These tests use ``respx`` to mock the upstream httpx transport so we can
|
||||
assert exactly what gets sent (headers, body) and drive the auto-login +
|
||||
401-relogin behaviour without touching the real CRM.
|
||||
|
||||
Phase-0 caveat: the real ``pcLogin`` request body / password encryption /
|
||||
response shape were not captured. The mocks here encode our documented
|
||||
assumptions (see ``app/session/login.py``): response JSON has a ``data``
|
||||
object with ``userid`` + ``company`` fields. When the real shapes are
|
||||
confirmed, only the parsing inside ``pc_login`` needs to change — these
|
||||
tests stay green.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.session.login import LoginResult
|
||||
from app.session.yunke_client import YunkeClient
|
||||
|
||||
BASE = "https://crm2.yunkecn.com"
|
||||
USERID = "a" * 32 # 32-hex user id, as observed in real auth headers
|
||||
COMPANY = "8m0atd"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
async def client():
|
||||
"""A YunkeClient wired for username/password login.
|
||||
|
||||
The fixture yields the client and always closes it so respx mocks set up
|
||||
inside individual tests don't leak cookies across tests.
|
||||
"""
|
||||
c = YunkeClient(base_url=BASE, username="tester", password="hunter2")
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
await c.aclose()
|
||||
|
||||
|
||||
def _stub_login_endpoints() -> tuple[respx.Route, respx.Route]:
|
||||
"""Register the getSecureKey + pcLogin mocks used across tests."""
|
||||
secure = respx.get(f"{BASE}/newuc/login/getSecureKey").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"data": {"modulus": "00ab", "exponent": "010001"}}
|
||||
)
|
||||
)
|
||||
login = respx.post(f"{BASE}/usercenter/login/pcLogin").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"code": 0, "data": {"userid": USERID, "company": COMPANY}},
|
||||
headers={"Set-Cookie": "JSESSIONID=deadbeef; Path=/"},
|
||||
)
|
||||
)
|
||||
return secure, login
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test A: login() populates LoginResult + the httpx client cookie jar
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_login_stores_userid_and_cookies(client: YunkeClient):
|
||||
_, login_route = _stub_login_endpoints()
|
||||
|
||||
result = await client.login()
|
||||
|
||||
assert login_route.called
|
||||
assert isinstance(result, LoginResult)
|
||||
assert result.userid == USERID
|
||||
assert result.company == COMPANY
|
||||
# LoginResult is the same object stored on the client.
|
||||
assert client.login_result is result
|
||||
# The session cookie from the mocked Set-Cookie must land in the httpx
|
||||
# client's cookie jar so subsequent requests carry it.
|
||||
assert client.client.cookies.get("JSESSIONID") == "deadbeef"
|
||||
# The snapshot on LoginResult should also contain it.
|
||||
assert result.cookies.get("JSESSIONID") == "deadbeef"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test B: every request carries the auth headers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_post_injects_auth_headers(client: YunkeClient):
|
||||
# Skip the login flow entirely — prime the client with a logged-in state.
|
||||
client.login_result = LoginResult(userid=USERID, company=COMPANY)
|
||||
client.client.cookies.set("JSESSIONID", "deadbeef")
|
||||
|
||||
route = respx.post(f"{BASE}/wechatweb/wxFriends/friend_list/es").mock(
|
||||
return_value=httpx.Response(200, json={"data": []})
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/wechatweb/wxFriends/friend_list/es", json={"pageNum": 1, "pageSize": 20}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert route.called
|
||||
|
||||
sent = route.calls.last.request
|
||||
# The 5 known auth headers must be on every outgoing request.
|
||||
assert sent.headers["userid"] == USERID
|
||||
assert sent.headers["company"] == COMPANY
|
||||
assert sent.headers["source"] == "yunkepc"
|
||||
assert sent.headers["channel"] == "4"
|
||||
assert sent.headers["content-type"] == "application/json"
|
||||
# And the session cookie travels along too.
|
||||
assert sent.headers.get("cookie", "").find("JSESSIONID=deadbeef") != -1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test C: 401 -> re-login once -> retry succeeds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_401_triggers_relogin_and_retry(client: YunkeClient):
|
||||
_, login_route = _stub_login_endpoints()
|
||||
data_route = respx.post(
|
||||
f"{BASE}/wechatweb/wxFriends/friend_list/es"
|
||||
).mock(
|
||||
side_effect=[
|
||||
httpx.Response(401, json={"msg": "session expired"}),
|
||||
httpx.Response(200, json={"data": [{"id": 1}]}),
|
||||
]
|
||||
)
|
||||
|
||||
# Prime as logged-in so the first call doesn't itself trigger an
|
||||
# auto-login; the 401 is what should drive exactly one re-login.
|
||||
client.login_result = LoginResult(userid=USERID, company=COMPANY)
|
||||
|
||||
resp = await client.post("/wechatweb/wxFriends/friend_list/es", json={})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"data": [{"id": 1}]}
|
||||
# Exactly one re-login + exactly one retry — no infinite loop.
|
||||
assert login_route.call_count == 1
|
||||
assert data_route.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test D: persistent 401 -> raise after the single retry (no loop)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_persistent_401_raises_after_one_retry(client: YunkeClient):
|
||||
_, login_route = _stub_login_endpoints()
|
||||
data_route = respx.post(
|
||||
f"{BASE}/wechatweb/wxFriends/friend_list/es"
|
||||
).mock(return_value=httpx.Response(401, json={"msg": "still expired"}))
|
||||
|
||||
client.login_result = LoginResult(userid=USERID, company=COMPANY)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await client.post("/wechatweb/wxFriends/friend_list/es", json={})
|
||||
|
||||
# The single retry happened, then we gave up and raised.
|
||||
assert login_route.call_count == 1
|
||||
assert data_route.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test E: auto-login on first request when no session yet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_first_request_auto_logs_in(client: YunkeClient):
|
||||
secure_route, login_route = _stub_login_endpoints()
|
||||
data_route = respx.post(f"{BASE}/wechatweb/wxFriends/friend_list/es").mock(
|
||||
return_value=httpx.Response(200, json={"data": []})
|
||||
)
|
||||
|
||||
# No priming — client should auto-login on first call.
|
||||
resp = await client.post("/wechatweb/wxFriends/friend_list/es", json={})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert secure_route.called
|
||||
assert login_route.called
|
||||
assert data_route.call_count == 1
|
||||
assert client.login_result is not None
|
||||
assert client.login_result.userid == USERID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test F: encrypt_password is a documented Phase-0 placeholder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_encrypt_password_is_swappable_phase0_placeholder():
|
||||
"""Phase 0: encrypt_password must be deterministic and non-trivial but is
|
||||
NOT the real RSA scheme. It must accept any secure-key shape and return a
|
||||
str so the login flow is end-to-end runnable in tests.
|
||||
"""
|
||||
from app.session.login import encrypt_password
|
||||
|
||||
out = encrypt_password("hunter2", {"modulus": "00ab", "exponent": "010001"})
|
||||
assert isinstance(out, str)
|
||||
assert out # non-empty
|
||||
# Deterministic for the same inputs (so tests / debugging are stable).
|
||||
assert encrypt_password("hunter2", {"modulus": "00ab"}) == out
|
||||
Reference in New Issue
Block a user