@@ -0,0 +1,92 @@
|
||||
"""MAX endpoints authenticate before reading bindings or accepting events."""
|
||||
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core import clock
|
||||
from app.db import repo, session
|
||||
from app.db.bootstrap import init_db
|
||||
from app.integrations import max_store
|
||||
from app.main import app
|
||||
|
||||
BASE = "/api/v1/me/notifications/max"
|
||||
HOOK = "/api/v1/webhooks/max"
|
||||
TOKEN = "synthetic-patient"
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
SECRET = "test-secret-" * 4
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch):
|
||||
for key, value in {
|
||||
"MAX_ENABLED": "true",
|
||||
"MAX_BOT_TOKEN": "fake-token",
|
||||
"MAX_BOT_USERNAME": "test_bot",
|
||||
"MAX_WEBHOOK_SECRET": SECRET,
|
||||
"MAX_WEBHOOK_URL": "https://api.example.org" + HOOK,
|
||||
"MAX_SITE_URL": "https://example.org",
|
||||
}.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
get_settings.cache_clear()
|
||||
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
|
||||
await init_db()
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=TOKEN, doctor_id="")
|
||||
await repo.set_dialog_state(
|
||||
s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=clock.now()
|
||||
)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
await session.configure()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("auth", [{}, {"Authorization": "Bearer unknown"}, {"Authorization": "Basic test"}])
|
||||
async def test_auth_required(client, auth):
|
||||
assert (await client.get(BASE, headers=auth)).status_code == 401
|
||||
assert (await client.post(BASE + "/link", headers=auth, json={"consent": True})).status_code == 401
|
||||
assert (await client.delete(BASE, headers=auth)).status_code == 401
|
||||
|
||||
|
||||
async def test_link_requires_consent_and_hides_patient_credential(client):
|
||||
for body in ({}, {"consent": False}, {"consent": True, "user_id": 100}):
|
||||
assert (await client.post(BASE + "/link", headers=AUTH, json=body)).status_code == 422
|
||||
response = await client.post(BASE + "/link", headers=AUTH, json={"consent": True})
|
||||
assert response.status_code == 200
|
||||
assert TOKEN not in response.text
|
||||
url = urlsplit(response.json()["url"])
|
||||
assert url.netloc == "max.ru" and url.path == "/test_bot"
|
||||
code = parse_qs(url.query)["start"][0]
|
||||
async with session.get_identity_session() as s:
|
||||
assert await max_store.bind(s, code, 100, clock.now()) == TOKEN
|
||||
assert (await client.get(BASE, headers=AUTH)).json()["connected"] is True
|
||||
assert (await client.delete(BASE, headers=AUTH)).json()["connected"] is False
|
||||
|
||||
|
||||
async def test_revoke_consent_prevents_linking(client):
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.set_dialog_state(
|
||||
s, token=TOKEN, state="idle", data={"consent": False, "adult": True}, now=clock.now()
|
||||
)
|
||||
assert (await client.post(BASE + "/link", headers=AUTH, json={"consent": True})).status_code == 403
|
||||
|
||||
|
||||
async def test_disabled_service_still_allows_disconnect(client, monkeypatch):
|
||||
monkeypatch.setenv("MAX_ENABLED", "false")
|
||||
get_settings.cache_clear()
|
||||
assert (await client.get(BASE, headers=AUTH)).json()["available"] is False
|
||||
assert (await client.post(BASE + "/link", headers=AUTH, json={"consent": True})).status_code == 503
|
||||
assert (await client.post(HOOK, json={})).status_code == 503
|
||||
assert (await client.delete(BASE, headers=AUTH)).status_code == 200
|
||||
|
||||
|
||||
async def test_webhook_secret_checked_before_body_or_adapter(client):
|
||||
assert (await client.post(HOOK, content="not-json")).status_code == 403
|
||||
assert (await client.post(HOOK, headers={"X-Max-Bot-Api-Secret": "wrong"}, json={})).status_code == 403
|
||||
headers = {"X-Max-Bot-Api-Secret": SECRET}
|
||||
assert (await client.post(HOOK, headers=headers, content="not-json")).status_code == 400
|
||||
assert (await client.post(HOOK, headers=headers, json=[])).status_code == 422
|
||||
assert (await client.post(HOOK, headers=headers, content=" " * 65537)).status_code == 413
|
||||
+9
-1
@@ -53,7 +53,15 @@ async def _tables(engine_fn: Callable[[], AsyncEngine]) -> set[str]:
|
||||
async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
|
||||
identity_tables = await _tables(session.identity_engine)
|
||||
clinical_tables = await _tables(session.clinical_engine)
|
||||
assert identity_tables == {"patients", "doctors", "connect_codes"}
|
||||
assert identity_tables == {
|
||||
"patients",
|
||||
"doctors",
|
||||
"connect_codes",
|
||||
"max_bindings",
|
||||
"max_link_codes",
|
||||
"max_webhook_receipts",
|
||||
"max_deliveries",
|
||||
}
|
||||
assert {
|
||||
"patient_profiles",
|
||||
"item_responses",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Persistent, one-time linking and queue recovery; synthetic identities only."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core import clock
|
||||
from app.db import repo, session
|
||||
from app.db.bootstrap import init_db
|
||||
from app.db.clinical import ScaleResult
|
||||
from app.db.max_notifications import MaxBinding, MaxDelivery, MaxLinkCode
|
||||
from app.integrations import max_store as store
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def database():
|
||||
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
|
||||
await init_db()
|
||||
yield
|
||||
await session.configure()
|
||||
|
||||
|
||||
async def test_codes_are_hashed_expiring_one_time_and_replaced():
|
||||
now = clock.now()
|
||||
async with session.get_identity_session() as s:
|
||||
old, _ = await store.issue_code(s, "patient", now, 60)
|
||||
code, expires = await store.issue_code(s, "patient", now, 60)
|
||||
assert await store.code_token(s, old, now) is None
|
||||
row = (await s.scalars(select(MaxLinkCode))).one()
|
||||
assert row.digest != code and row.digest == store.digest(code)
|
||||
assert await store.code_token(s, code, expires) is None
|
||||
assert await store.bind(s, code, 100, now) == "patient"
|
||||
assert await store.bind(s, code, 100, now) is None
|
||||
async with session.get_identity_session() as s:
|
||||
assert (await s.get(MaxBinding, "patient")).user_id == 100
|
||||
|
||||
|
||||
async def test_active_binding_cannot_be_reassigned_in_either_direction():
|
||||
now = clock.now()
|
||||
async with session.get_identity_session() as s:
|
||||
code, _ = await store.issue_code(s, "one", now, 60)
|
||||
await store.bind(s, code, 100, now)
|
||||
code, _ = await store.issue_code(s, "two", now, 60)
|
||||
assert await store.bind(s, code, 100, now) is None
|
||||
assert await store.code_token(s, code, now) == "two"
|
||||
code, _ = await store.issue_code(s, "one", now, 60)
|
||||
assert await store.bind(s, code, 200, now) is None
|
||||
await store.disconnect(s, "one")
|
||||
assert await store.code_token(s, code, now) is None
|
||||
code, _ = await store.issue_code(s, "two", now, 60)
|
||||
assert await store.bind(s, code, 100, now) == "two"
|
||||
|
||||
|
||||
async def test_disconnect_cancels_pending_delivery_and_prevents_stale_ack():
|
||||
now = clock.now()
|
||||
async with session.get_identity_session() as s:
|
||||
await store.enqueue(s, key="job", user_id=100, token="one", kind="notification", now=now)
|
||||
row = await store.claim(s, now)
|
||||
async with session.get_identity_session() as s:
|
||||
await store.disconnect(s, "one")
|
||||
await store.finish(s, row, state="sent", now=now)
|
||||
async with session.get_identity_session() as s:
|
||||
assert (await s.get(MaxDelivery, "job")).state == "cancelled"
|
||||
|
||||
|
||||
async def test_queue_survives_sessions_deduplicates_and_recovers_expired_lease():
|
||||
now = clock.now()
|
||||
async with session.get_identity_session() as s:
|
||||
for _ in range(2):
|
||||
await store.enqueue(s, key="job", user_id=100, kind="help", now=now)
|
||||
async with session.get_identity_session() as s:
|
||||
first = await store.claim(s, now)
|
||||
assert first.attempts == 1
|
||||
async with session.get_identity_session() as s:
|
||||
assert await store.claim(s, now + timedelta(seconds=59)) is None
|
||||
async with session.get_identity_session() as s:
|
||||
second = await store.claim(s, now + timedelta(seconds=61))
|
||||
assert second.id == first.id and second.lease != first.lease and second.attempts == 2
|
||||
async with session.get_identity_session() as s:
|
||||
await store.finish(s, first, state="sent", now=now)
|
||||
async with session.get_identity_session() as s:
|
||||
assert (await s.get(MaxDelivery, "job")).state == "sending"
|
||||
await store.finish(s, second, state="sent", now=now)
|
||||
async with session.get_identity_session() as s:
|
||||
assert await store.claim(s, now + timedelta(days=1)) is None
|
||||
|
||||
|
||||
async def test_retry_deadline_and_attempt_limit():
|
||||
now = clock.now()
|
||||
async with session.get_identity_session() as s:
|
||||
await store.enqueue(s, key="job", user_id=100, kind="help", now=now)
|
||||
for attempt in range(store.MAX_ATTEMPTS):
|
||||
when = now + timedelta(seconds=attempt * 120)
|
||||
async with session.get_identity_session() as s:
|
||||
row = await store.claim(s, when)
|
||||
async with session.get_identity_session() as s:
|
||||
await store.finish(s, row, state="queued", now=when, retry_seconds=120, error="http_429")
|
||||
async with session.get_identity_session() as s:
|
||||
assert await store.claim(s, when + timedelta(seconds=119)) is None
|
||||
async with session.get_identity_session() as s:
|
||||
assert await store.claim(s, now + timedelta(days=1)) is None
|
||||
async with session.get_identity_session() as s:
|
||||
row = await s.get(MaxDelivery, "job")
|
||||
assert row.state == "failed" and row.attempts == store.MAX_ATTEMPTS
|
||||
|
||||
|
||||
async def test_completion_scan_ignores_old_and_incomplete_tests_and_recovers_without_duplicates():
|
||||
now = clock.now()
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token="one", doctor_id="")
|
||||
await repo.set_dialog_state(s, token="one", state="idle", data={"consent": True, "adult": True}, now=now)
|
||||
for ts, complete in ((now - timedelta(days=1), True), (now, True), (now, False)):
|
||||
s.add(ScaleResult(token="one", ts=ts, scale="phq9", total=0, severity="none", complete=complete))
|
||||
async with session.get_identity_session() as s:
|
||||
s.add(MaxBinding(token="one", user_id=100, enabled=True, connected_at=now))
|
||||
for _ in range(2):
|
||||
async with session.get_identity_session() as s:
|
||||
await store.collect_completed(s, now)
|
||||
async with session.get_identity_session() as s:
|
||||
row = (await s.scalars(select(MaxDelivery))).one()
|
||||
assert row.kind == "completed" and row.token == "one"
|
||||
assert "phq9" not in row.id
|
||||
Reference in New Issue
Block a user