新增ERP扫码登录小程序后端代理

This commit is contained in:
汤学会
2026-07-06 01:28:12 +08:00
parent 8f62c99893
commit 1cff7e06aa
7 changed files with 586 additions and 5 deletions
+222
View File
@@ -0,0 +1,222 @@
import asyncio
import hmac
import json
import time
from hashlib import sha256
from uuid import uuid4
import httpx
from fastapi.testclient import TestClient
from app.config import settings
from app.main import create_app
from app.services import wechat
SERVICE_SECRET = "test-erp-secret"
def _json_bytes(payload: dict) -> bytes:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
def _test_signature(
method: str,
path: str,
timestamp: str,
nonce: str,
body: bytes,
secret: str = SERVICE_SECRET,
) -> str:
message = "\n".join([
method.upper(),
path,
timestamp,
nonce,
body.decode("utf-8"),
])
return hmac.new(secret.encode("utf-8"), message.encode("utf-8"), sha256).hexdigest()
def _signed_headers(
method: str,
path: str,
body: bytes,
secret: str = SERVICE_SECRET,
) -> dict[str, str]:
timestamp = str(int(time.time()))
nonce = uuid4().hex
return {
"x-service-timestamp": timestamp,
"x-service-nonce": nonce,
"x-service-signature": _test_signature(method, path, timestamp, nonce, body, secret),
"content-type": "application/json",
}
def _client(monkeypatch, erp_base_url: str = "http://erp.local") -> TestClient:
monkeypatch.setattr(settings, "erp_qr_login_shared_secret", SERVICE_SECRET)
monkeypatch.setattr(settings, "erp_api_base_url", erp_base_url)
monkeypatch.setattr(settings, "erp_login_qrcode_dir", "erp-login-qrcodes")
monkeypatch.setattr(settings, "public_base_url", "https://miniapp.example.com")
return TestClient(create_app())
def test_erp_login_router_registered(monkeypatch):
client = _client(monkeypatch)
response = client.get("/api/erp-login/sessions/ticket-abc")
assert response.status_code == 400
assert response.json()["detail"] == "缺少ERP扫码登录会话ID"
def test_qrcode_rejects_missing_or_bad_service_signature(monkeypatch):
client = _client(monkeypatch)
body = _json_bytes({"ticket": "ticket-abc", "session_id": 123})
missing = client.post(
"/api/erp-login/qrcode",
content=body,
headers={"content-type": "application/json"},
)
bad_headers = _signed_headers("POST", "/api/erp-login/qrcode", body)
bad_headers["x-service-signature"] = "bad-signature"
bad = client.post("/api/erp-login/qrcode", content=body, headers=bad_headers)
assert missing.status_code == 401
assert bad.status_code == 401
def test_qrcode_signed_request_uses_expected_miniapp_qrcode_contract(monkeypatch):
from app.routers import erp_login
calls = []
async def fake_create_miniapp_qrcode(**kwargs):
calls.append(kwargs)
return "https://miniapp.example.com/uploads/erp-login-qrcodes/erp-login-ticket-abc.png"
monkeypatch.setattr(erp_login, "create_miniapp_qrcode", fake_create_miniapp_qrcode)
client = _client(monkeypatch)
body = _json_bytes({"ticket": "ticket-abc", "session_id": 123})
response = client.post(
"/api/erp-login/qrcode",
content=body,
headers=_signed_headers("POST", "/api/erp-login/qrcode", body),
)
assert response.status_code == 200
assert response.json() == {
"qr_url": "https://miniapp.example.com/uploads/erp-login-qrcodes/erp-login-ticket-abc.png",
"page": "pages/erpLoginConfirm/erpLoginConfirm",
"scene": "ticket=ticket-abc&session_id=123",
}
assert calls == [{
"key": "erp-login-ticket-abc",
"page": "pages/erpLoginConfirm/erpLoginConfirm",
"scene": "ticket=ticket-abc&session_id=123",
"public_base_url": "https://miniapp.example.com",
"directory": "erp-login-qrcodes",
"label": "ERP扫码登录",
}]
def test_preview_requires_session_id(monkeypatch):
client = _client(monkeypatch)
response = client.get("/api/erp-login/sessions/ticket-abc")
assert response.status_code == 400
assert response.json()["detail"] == "缺少ERP扫码登录会话ID"
def test_confirm_gets_phone_signs_erp_request_with_base_path_and_hides_auth_session(monkeypatch):
from app.routers import erp_login
async def fake_get_phone_number(phone_code: str) -> str:
assert phone_code == "wx-phone-code"
return "13800000000"
captured: dict[str, object] = {}
original_async_client = httpx.AsyncClient
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["body"] = request.content
captured["headers"] = request.headers
assert request.url.path == "/erp-prefix/api/auth/qr-login/sessions/123/confirm"
timestamp = request.headers["x-service-timestamp"]
nonce = request.headers["x-service-nonce"]
expected = _test_signature(
"POST",
"/erp-prefix/api/auth/qr-login/sessions/123/confirm",
timestamp,
nonce,
request.content,
)
assert hmac.compare_digest(request.headers["x-service-signature"], expected)
return httpx.Response(
200,
json={
"status": "confirmed",
"failure_reason": None,
"access_token": "must-not-leak",
"user": {"phone": "13800000000"},
},
)
transport = httpx.MockTransport(handler)
def fake_async_client(**kwargs):
_ = kwargs
return original_async_client(transport=transport)
monkeypatch.setattr(erp_login, "get_phone_number", fake_get_phone_number)
monkeypatch.setattr(erp_login.httpx, "AsyncClient", fake_async_client)
client = _client(monkeypatch, erp_base_url="http://erp.local/erp-prefix")
response = client.post(
"/api/erp-login/sessions/ticket-abc/confirm?session_id=123",
json={"phone_code": "wx-phone-code"},
)
assert response.status_code == 200
assert response.json() == {"status": "confirmed", "failure_reason": None}
assert "access_token" not in response.json()
erp_body = json.loads(captured["body"].decode("utf-8"))
assert erp_body["ticket"] == "ticket-abc"
assert erp_body["phone"] == "13800000000"
assert erp_body["confirmed_at"]
assert erp_body["nonce"]
def test_create_device_qrcode_delegates_to_miniapp_qrcode_directory(monkeypatch):
calls = []
async def fake_create_miniapp_qrcode(**kwargs):
calls.append(kwargs)
return "https://miniapp.example.com/uploads/qrcodes/device.png"
monkeypatch.setattr(wechat, "create_miniapp_qrcode", fake_create_miniapp_qrcode, raising=False)
result = asyncio.run(
wechat.create_device_qrcode(
"28#",
page="pages/clock/clock",
scene="mold=abc",
public_base_url="https://miniapp.example.com",
label="模具 28#",
)
)
assert result == "https://miniapp.example.com/uploads/qrcodes/device.png"
assert calls == [{
"key": "28#",
"page": "pages/clock/clock",
"scene": "mold=abc",
"public_base_url": "https://miniapp.example.com",
"directory": "qrcodes",
"label": "模具 28#",
}]