"""Webhook от gnexus-auth (ТЗ 3.16): подпись, события профиля, отзыв MCP-токенов."""
import hashlib
import hmac
import json
import time
from typing import Any
from sqlalchemy import select
from app.models import McpToken, User
from tests.conftest import _test_session_factory
WEBHOOK_SECRET = "whsec-test"
def _signed_headers(
body: str, event_type: str = "webhook.test", secret: str = WEBHOOK_SECRET
) -> dict[str, str]:
ts = str(int(time.time()))
sig = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
return {
"X-GNexus-Event-Id": "evt-1",
"X-GNexus-Event-Type": event_type,
"X-GNexus-Event-Timestamp": ts,
"X-GNexus-Signature": f"t={ts},v1={sig}",
}
def _enable_webhook(monkeypatch: Any) -> None:
"""GAUTH_WEBHOOK_SECRET читается через get_settings в момент запроса."""
import app.auth.webhooks as wh
class _Settings:
gauth_webhook_secret = WEBHOOK_SECRET
monkeypatch.setattr(wh, "get_settings", lambda: _Settings())
def _post(client: Any, payload: dict, event_type: str, secret: str = WEBHOOK_SECRET) -> Any:
body = json.dumps(payload)
return client.post(
"/auth/webhook",
content=body,
headers={"Content-Type": "application/json", **_signed_headers(body, event_type, secret)},
)
def test_webhook_disabled_without_secret(client: Any, monkeypatch: Any) -> None:
# секрет не задан (дефолт Settings) — эндпоинт выключен
resp = _post(client, {"type": "webhook.test"}, "webhook.test")
assert resp.status_code == 503
def test_webhook_test_event_accepted(client: Any, monkeypatch: Any) -> None:
_enable_webhook(monkeypatch)
resp = _post(client, {"type": "webhook.test", "data": {}}, "webhook.test")
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_webhook_invalid_signature_rejected(client: Any, monkeypatch: Any) -> None:
_enable_webhook(monkeypatch)
body = json.dumps({"type": "webhook.test"})
resp = client.post(
"/auth/webhook",
content=body,
headers={
"Content-Type": "application/json",
**_signed_headers(body, "webhook.test", "wrong-secret"),
},
)
assert resp.status_code == 400
def test_webhook_garbage_payload_rejected(client: Any, monkeypatch: Any) -> None:
_enable_webhook(monkeypatch)
body = "not json"
resp = client.post(
"/auth/webhook",
content=body,
headers={"Content-Type": "application/json", **_signed_headers(body)},
)
assert resp.status_code == 400
def test_user_blocked_revokes_mcp_tokens(client: Any, monkeypatch: Any) -> None:
_enable_webhook(monkeypatch)
session = _test_session_factory()
try:
session.add(McpToken(user_id="u1", token_hash="a" * 64, label="x"))
session.add(McpToken(user_id="u2", token_hash="b" * 64, label="y"))
session.commit()
resp = _post(
client,
{"type": "user.blocked", "data": {"user": {"id": "u1"}}},
"user.blocked",
)
assert resp.status_code == 200
remaining = session.scalars(select(McpToken).where(McpToken.user_id == "u1")).all()
assert remaining == []
others = session.scalars(select(McpToken).where(McpToken.user_id == "u2")).all()
assert len(others) == 1
finally:
session.close()
def test_user_deleted_revokes_mcp_tokens(client: Any, monkeypatch: Any) -> None:
_enable_webhook(monkeypatch)
session = _test_session_factory()
try:
session.add(McpToken(user_id="u1", token_hash="a" * 64, label="x"))
session.commit()
# user_id в target (после удаления персональные данные не передаются)
resp = _post(client, {"type": "user.deleted", "target": {"user_id": "u1"}}, "user.deleted")
assert resp.status_code == 200
assert session.scalars(select(McpToken).where(McpToken.user_id == "u1")).all() == []
finally:
session.close()
def test_email_changed_updates_user(client: Any, monkeypatch: Any) -> None:
_enable_webhook(monkeypatch)
session = _test_session_factory()
try:
session.add(User(id="u1", email="old@example.com", avatar_url=None, locale="ru"))
session.commit()
resp = _post(
client,
{
"type": "user.email_changed",
"data": {"user": {"id": "u1"}, "new_email": "new@example.com"},
},
"user.email_changed",
)
assert resp.status_code == 200
session.expire_all()
assert session.get(User, "u1").email == "new@example.com"
finally:
session.close()
def test_profile_updated_syncs_locale_and_avatar(client: Any, monkeypatch: Any) -> None:
_enable_webhook(monkeypatch)
session = _test_session_factory()
try:
session.add(User(id="u1", email="u@example.com", avatar_url=None, locale="ru"))
session.commit()
resp = _post(
client,
{
"type": "user.profile_updated",
"data": {
"user": {"id": "u1"},
"profile": {"locale": "uk", "avatar_url": "https://sso/avatar.jpg"},
},
},
"user.profile_updated",
)
assert resp.status_code == 200
session.expire_all()
user = session.get(User, "u1")
assert user.locale == "uk"
assert user.avatar_url == "https://sso/avatar.jpg"
finally:
session.close()
def test_unknown_event_type_is_acked(client: Any, monkeypatch: Any) -> None:
"""Незнакомый тип SSO подтверждаем 200 — без retry-шторма с его стороны."""
_enable_webhook(monkeypatch)
resp = _post(
client,
{"type": "group.user_added", "data": {"user": {"id": "u1"}}},
"group.user_added",
)
assert resp.status_code == 200