428 lines
14 KiB
Python
428 lines
14 KiB
Python
"""Contract tests for the UI-independent API client and token store."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from threading import Barrier, get_ident
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from doctor_workstation.core.errors import (
|
|
ApiBusinessError,
|
|
ApiError,
|
|
ApiProtocolError,
|
|
ApiTimeoutError,
|
|
AuthenticationExpiredError,
|
|
OpenPageRequiredError,
|
|
WorkWechatBindingRequiredError,
|
|
)
|
|
from doctor_workstation.services import api_client as api_client_module
|
|
from doctor_workstation.services.api_client import ApiClient
|
|
from doctor_workstation.services.token_store import TokenStore
|
|
|
|
|
|
def test_get_normalises_adminapi_and_sends_contract_headers() -> None:
|
|
"""The site base and already-prefixed base resolve to the same API URL."""
|
|
|
|
requests: list[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(200, json={"code": 1, "data": {"ok": True}})
|
|
|
|
with ApiClient(
|
|
"https://example.test/root/",
|
|
token="secret-token",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
assert client.get("/doctor.appointment/lists", {"page_no": 2}) == {"ok": True}
|
|
|
|
request = requests[0]
|
|
assert str(request.url) == (
|
|
"https://example.test/root/adminapi/doctor.appointment/lists?page_no=2"
|
|
)
|
|
assert request.headers["token"] == "secret-token"
|
|
assert request.headers["version"] == "1.9.4"
|
|
assert ApiClient.normalise_base_url("https://example.test/adminapi") == (
|
|
"https://example.test/adminapi/"
|
|
)
|
|
|
|
|
|
def test_default_client_allows_parallel_requests_with_independent_transports(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Production workers must not queue behind one process-wide HTTP lock."""
|
|
|
|
rendezvous = Barrier(2, timeout=2)
|
|
created: list[Any] = []
|
|
request_threads: set[int] = set()
|
|
|
|
class PooledClient:
|
|
def __init__(self, **_options: Any) -> None:
|
|
self.closed = False
|
|
created.append(self)
|
|
|
|
def request(self, _method: str, url: str, **_options: Any) -> httpx.Response:
|
|
request_threads.add(get_ident())
|
|
rendezvous.wait()
|
|
return httpx.Response(200, json={"code": 1, "data": url.rsplit("/", 1)[-1]})
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
monkeypatch.setattr(api_client_module.httpx, "Client", PooledClient)
|
|
client = ApiClient("https://example.test", max_retries=0)
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
first = executor.submit(client.get, "patient/first")
|
|
second = executor.submit(client.get, "patient/second")
|
|
assert {first.result(timeout=3), second.result(timeout=3)} == {"first", "second"}
|
|
client.close()
|
|
|
|
assert len(created) == 2
|
|
assert len(request_threads) == 2
|
|
assert all(item.closed for item in created)
|
|
|
|
|
|
def test_post_uses_json_and_never_retries_timeout() -> None:
|
|
"""Writes use JSON and a timeout never causes an automatic duplicate POST."""
|
|
|
|
attempts = 0
|
|
bodies: list[dict[str, object]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
nonlocal attempts
|
|
attempts += 1
|
|
bodies.append(json.loads(request.content))
|
|
raise httpx.ReadTimeout("slow write", request=request)
|
|
|
|
client = ApiClient(
|
|
"https://example.test",
|
|
max_retries=5,
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
with pytest.raises(ApiTimeoutError) as caught:
|
|
client.post("doctor.appointment/complete", {"id": 42})
|
|
client.close()
|
|
|
|
assert attempts == 1
|
|
assert bodies == [{"id": 42}]
|
|
assert caught.value.data["attempts"] == 1
|
|
|
|
|
|
def test_post_event_stream_sends_exact_contract_and_preserves_event_order() -> None:
|
|
requests: list[httpx.Request] = []
|
|
content = (
|
|
'event: start\ndata: {"model_key":"qwen"}\n\n'
|
|
'event: delta\ndata: {"content":"辨"}\n\n'
|
|
'event: delta\ndata: {"content":"证"}\n\n'
|
|
'event: done\ndata: {"model_label":"千问"}\n\n'
|
|
)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream; charset=utf-8"},
|
|
text=content,
|
|
)
|
|
|
|
client = ApiClient(
|
|
"https://example.test",
|
|
token="stream-token",
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
events = list(
|
|
client.post_event_stream(
|
|
"tcm.diagnosis/aiAssistantStream",
|
|
{"id": 501, "task": "custom", "prompt": "如何辨证?"},
|
|
)
|
|
)
|
|
client.close()
|
|
|
|
assert [event["event"] for event in events] == ["start", "delta", "delta", "done"]
|
|
assert [event["data"] for event in events[1:3]] == [
|
|
{"content": "辨"},
|
|
{"content": "证"},
|
|
]
|
|
request = requests[0]
|
|
assert request.headers["accept"] == "text/event-stream"
|
|
assert request.headers["token"] == "stream-token"
|
|
assert str(request.url).endswith("/adminapi/tcm.diagnosis/aiAssistantStream")
|
|
assert json.loads(request.content) == {
|
|
"id": 501,
|
|
"task": "custom",
|
|
"prompt": "如何辨证?",
|
|
}
|
|
|
|
|
|
def test_multipart_post_lets_httpx_set_boundary_and_sends_form_fields(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Uploads use real multipart encoding without the JSON content type."""
|
|
|
|
requests: list[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(
|
|
200,
|
|
json={"code": 1, "data": {"uri": "/uploads/demo.jpg"}},
|
|
)
|
|
|
|
source = tmp_path / "demo.jpg"
|
|
source.write_bytes(b"jpeg-demo-bytes")
|
|
with (
|
|
ApiClient(
|
|
"https://example.test",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client,
|
|
source.open("rb") as stream,
|
|
):
|
|
result = client.post_multipart(
|
|
"upload/image",
|
|
files={"file": (source.name, stream, "image/jpeg")},
|
|
data={"cid": "0"},
|
|
)
|
|
|
|
assert result == {"uri": "/uploads/demo.jpg"}
|
|
request = requests[0]
|
|
content_type = request.headers["content-type"]
|
|
assert content_type.startswith("multipart/form-data; boundary=")
|
|
assert "application/json" not in content_type
|
|
assert b'name="file"; filename="demo.jpg"' in request.content
|
|
assert b'name="cid"' in request.content
|
|
assert b"jpeg-demo-bytes" in request.content
|
|
|
|
|
|
def test_get_retries_only_timeouts_then_returns_data() -> None:
|
|
"""A GET may recover from a bounded number of timeout failures."""
|
|
|
|
attempts = 0
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
nonlocal attempts
|
|
attempts += 1
|
|
if attempts < 3:
|
|
raise httpx.ReadTimeout("temporary", request=request)
|
|
return httpx.Response(200, json={"code": "1", "data": ["ready"]})
|
|
|
|
with ApiClient(
|
|
"https://example.test/adminapi/",
|
|
max_retries=2,
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
assert client.get("health") == ["ready"]
|
|
assert attempts == 3
|
|
|
|
|
|
def test_get_bytes_downloads_relative_public_image_without_api_token() -> None:
|
|
"""Generated QR images bypass the JSON envelope and never leak the API token."""
|
|
|
|
requests: list[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(200, headers={"content-type": "image/png"}, content=b"png-data")
|
|
|
|
with ApiClient(
|
|
"https://example.test/root",
|
|
token="private-token",
|
|
transport=httpx.MockTransport(handler),
|
|
) as client:
|
|
assert client.get_bytes("/uploads/qrcode.png") == b"png-data"
|
|
|
|
request = requests[0]
|
|
assert str(request.url) == "https://example.test/uploads/qrcode.png"
|
|
assert "token" not in request.headers
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("code", "exception_type"),
|
|
[
|
|
(0, ApiBusinessError),
|
|
(-1, AuthenticationExpiredError),
|
|
(10, WorkWechatBindingRequiredError),
|
|
],
|
|
)
|
|
def test_envelope_error_codes_are_structured(code: int, exception_type: type[Exception]) -> None:
|
|
"""Known control-flow codes become typed exceptions with response data."""
|
|
|
|
transport = httpx.MockTransport(
|
|
lambda request: httpx.Response(
|
|
200,
|
|
headers={"x-request-id": "req-123"},
|
|
json={"code": code, "msg": "action needed", "data": {"reason": "demo"}},
|
|
)
|
|
)
|
|
with (
|
|
ApiClient("https://example.test", transport=transport) as client,
|
|
pytest.raises(exception_type) as caught,
|
|
):
|
|
client.get("auth.admin/mySelf")
|
|
error = caught.value
|
|
assert isinstance(error, ApiError)
|
|
assert error.code == code
|
|
assert error.data == {"reason": "demo"}
|
|
assert error.request_id == "req-123"
|
|
|
|
|
|
def test_open_page_signal_does_not_open_a_browser() -> None:
|
|
"""Code 2 is surfaced to the UI as data, not executed by the service layer."""
|
|
|
|
transport = httpx.MockTransport(
|
|
lambda request: httpx.Response(
|
|
200,
|
|
json={"code": 2, "data": {"url": "https://example.test/continue"}},
|
|
)
|
|
)
|
|
with (
|
|
ApiClient("https://example.test", transport=transport) as client,
|
|
pytest.raises(OpenPageRequiredError) as caught,
|
|
):
|
|
client.get("continue")
|
|
assert caught.value.url == "https://example.test/continue"
|
|
|
|
|
|
def test_invalid_envelope_raises_protocol_error() -> None:
|
|
"""Successful HTTP is not mistaken for API success without a valid envelope."""
|
|
|
|
transport = httpx.MockTransport(
|
|
lambda request: httpx.Response(200, json={"data": "missing code"})
|
|
)
|
|
with (
|
|
ApiClient("https://example.test", transport=transport) as client,
|
|
pytest.raises(ApiProtocolError),
|
|
):
|
|
client.get("broken")
|
|
|
|
|
|
def test_token_store_file_fallback_never_persists_plaintext_password(tmp_path: Path) -> None:
|
|
"""The fallback may contain a Windows DPAPI blob, but never plaintext."""
|
|
|
|
path = tmp_path / "credentials.json"
|
|
store = TokenStore(path, keyring_backend=None)
|
|
store.save_token("token-value", account="doctor")
|
|
password_saved = store.save_password(
|
|
"must-not-reach-disk",
|
|
account="doctor",
|
|
scope="https://example.test/adminapi",
|
|
)
|
|
|
|
assert store.load_token() == "token-value"
|
|
assert store.load_account() == "doctor"
|
|
restored = store.load_password(account="doctor", scope="https://example.test/adminapi")
|
|
if password_saved:
|
|
assert restored == "must-not-reach-disk"
|
|
else:
|
|
assert restored is None
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
assert payload["token"] == "token-value"
|
|
assert payload["account"] == "doctor"
|
|
assert "password" not in path.read_text(encoding="utf-8").lower()
|
|
assert "must-not-reach-disk" not in path.read_text(encoding="utf-8")
|
|
store.clear_token()
|
|
assert store.load_token() is None
|
|
assert store.load_account() == "doctor"
|
|
after_logout = store.load_password(
|
|
account="doctor",
|
|
scope="https://example.test/adminapi",
|
|
)
|
|
if password_saved:
|
|
assert after_logout == "must-not-reach-disk"
|
|
else:
|
|
assert after_logout is None
|
|
store.clear_account()
|
|
assert store.load_password(account="doctor", scope="https://example.test/adminapi") is None
|
|
|
|
|
|
class _MemoryKeyring:
|
|
"""Minimal deterministic keyring double."""
|
|
|
|
def __init__(self) -> None:
|
|
self.values: dict[tuple[str, str], str] = {}
|
|
|
|
def get_password(self, service: str, username: str) -> str | None:
|
|
"""Return an in-memory secret."""
|
|
|
|
return self.values.get((service, username))
|
|
|
|
def set_password(self, service: str, username: str, password: str) -> None:
|
|
"""Store an in-memory secret."""
|
|
|
|
self.values[(service, username)] = password
|
|
|
|
def delete_password(self, service: str, username: str) -> None:
|
|
"""Delete an in-memory secret."""
|
|
|
|
self.values.pop((service, username), None)
|
|
|
|
|
|
def test_token_store_prefers_available_keyring(tmp_path: Path) -> None:
|
|
"""A working keyring keeps the token out of the fallback JSON file."""
|
|
|
|
backend = _MemoryKeyring()
|
|
path = tmp_path / "credentials.json"
|
|
store = TokenStore(path, keyring_backend=backend)
|
|
store.save_token("keyring-token", account="doctor")
|
|
|
|
assert store.uses_keyring
|
|
assert store.load_token() == "keyring-token"
|
|
assert json.loads(path.read_text(encoding="utf-8")) == {"account": "doctor"}
|
|
|
|
|
|
def test_token_store_keeps_login_password_in_scoped_keyring_only(tmp_path: Path) -> None:
|
|
backend = _MemoryKeyring()
|
|
path = tmp_path / "credentials.json"
|
|
store = TokenStore(path, keyring_backend=backend)
|
|
scope = "https://example.test/adminapi/"
|
|
|
|
assert store.save_password("secret-value", account="doctor", scope=scope)
|
|
assert (
|
|
store.load_password(account="doctor", scope="https://example.test/adminapi")
|
|
== "secret-value"
|
|
)
|
|
assert store.load_password(account="doctor", scope="https://other.test/adminapi") is None
|
|
assert "secret-value" not in path.read_text(encoding="utf-8")
|
|
assert json.loads(path.read_text(encoding="utf-8")) == {
|
|
"account": "doctor",
|
|
"scope": "https://example.test/adminapi",
|
|
}
|
|
|
|
next_scope = "https://next.test/adminapi"
|
|
assert store.save_password("next-secret", account="doctor", scope=next_scope)
|
|
assert store.load_password(account="doctor", scope=scope) is None
|
|
assert store.load_password(account="doctor", scope=next_scope) == "next-secret"
|
|
|
|
store.clear_password(account="doctor", scope=next_scope)
|
|
assert store.load_password(account="doctor", scope=next_scope) is None
|
|
|
|
|
|
def test_token_store_scopes_automatic_restore_and_forgets_account(tmp_path: Path) -> None:
|
|
"""Automatic restore never returns a token issued for another API base."""
|
|
|
|
path = tmp_path / "credentials.json"
|
|
store = TokenStore(path, keyring_backend=None)
|
|
api_scope = "https://example.test/adminapi/"
|
|
store.save_token(
|
|
"scoped-token",
|
|
account="doctor",
|
|
scope=api_scope,
|
|
)
|
|
|
|
assert store.load_token(scope="https://example.test/adminapi") == "scoped-token"
|
|
assert store.load_token(scope="https://other.test/adminapi/") is None
|
|
assert store.load_token() == "scoped-token"
|
|
assert json.loads(path.read_text(encoding="utf-8")) == {
|
|
"token": "scoped-token",
|
|
"account": "doctor",
|
|
"scope": "https://example.test/adminapi",
|
|
}
|
|
|
|
store.save_token("next-token", account="", scope=api_scope)
|
|
assert store.load_account() is None
|
|
assert "account" not in json.loads(path.read_text(encoding="utf-8"))
|