"""Unit tests for the gnexus-auth webhook receiver — HMAC verification and event routing."""
import hashlib
import hmac
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from gnexus_gauth.client import GAuthClient
from gnexus_gauth.config import GAuthConfig
from gnexus_gauth.oauth import HttpTokenEndpoint
from gnexus_gauth.runtime import HttpRuntimeUserProvider
from gnexus_gauth.support import InMemoryPkceStore, InMemoryStateStore
from gnexus_gauth.webhook import HmacWebhookVerifier, JsonWebhookParser
from navi.config import Settings
SECRET = "webhook-secret"
def _make_gauth_client() -> GAuthClient:
"""Real GAuthClient (its webhook verifier is the code under test)."""
config = GAuthConfig(
base_url="https://auth.test",
client_id="cid",
client_secret="csecret",
redirect_uri="https://navi.test/auth/callback",
)
return GAuthClient(
config=config,
token_endpoint=HttpTokenEndpoint(config),
runtime_user_provider=HttpRuntimeUserProvider(config),
webhook_verifier=HmacWebhookVerifier(config),
webhook_parser=JsonWebhookParser(),
state_store=InMemoryStateStore(),
pkce_store=InMemoryPkceStore(),
)
def _sign(body: str, timestamp: int | None = None) -> dict:
"""Build the headers gnexus-auth sends with a valid HMAC signature."""
ts = int(time.time()) if timestamp is None else timestamp
digest = hmac.new(SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
return {
"x-gnexus-event-id": "evt-1",
"x-gnexus-event-type": "user.blocked",
"x-gnexus-event-timestamp": str(ts),
"x-gnexus-signature": f"t={ts},v1={digest}",
}
@pytest.fixture
def webhook_client(monkeypatch):
"""TestClient with a real GAuthClient webhook verifier and patched settings."""
import navi.api.routes.webhooks as webhooks_mod
new_settings = Settings(
_env_file=None,
navi_persona_file="",
gnauth_client_id="cid",
gnauth_client_secret="csecret",
gnauth_webhook_secret=SECRET,
navi_auth_enabled=True,
)
monkeypatch.setattr(webhooks_mod, "settings", new_settings)
monkeypatch.setattr(webhooks_mod, "get_gauth_client", _make_gauth_client)
from navi.main import app
return TestClient(app), new_settings
def _post(client: TestClient, body: str, headers: dict | None = None):
return client.post(
"/webhooks/gnexus-auth",
content=body,
headers=headers or {"content-type": "application/json"},
)
def test_webhook_403_on_invalid_signature(webhook_client):
tc, _ = webhook_client
body = json.dumps({"type": "auth.global_logout"})
headers = _sign(body)
headers["x-gnexus-signature"] = "t=123,v1=deadbeef"
resp = _post(tc, body, headers)
assert resp.status_code == 403
assert "signature" in resp.json()["detail"].lower()
def test_webhook_403_on_stale_timestamp(webhook_client):
"""A correct signature over an hour-old timestamp must be rejected (replay)."""
tc, _ = webhook_client
body = json.dumps({"type": "auth.global_logout"})
headers = _sign(body, timestamp=int(time.time()) - 3600)
resp = _post(tc, body, headers)
assert resp.status_code == 403
def test_webhook_403_on_missing_signature_headers(webhook_client):
tc, _ = webhook_client
body = json.dumps({"type": "auth.global_logout"})
resp = _post(tc, body)
assert resp.status_code == 403
def test_webhook_400_on_valid_signature_bad_json(webhook_client):
tc, _ = webhook_client
body = "not-json{"
resp = _post(tc, body, _sign(body))
assert resp.status_code == 400
def test_webhook_200_processes_user_blocked(webhook_client, monkeypatch):
tc, _ = webhook_client
body = json.dumps({"type": "user.blocked", "target": {"user_id": "u1"}})
headers = _sign(body)
headers["x-gnexus-event-type"] = "user.blocked"
invalidate = AsyncMock()
monkeypatch.setattr(
"navi.api.routes.webhooks._invalidate_user_sessions", invalidate
)
resp = _post(tc, body, headers)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
invalidate.assert_awaited_once_with("u1")
def test_webhook_503_when_secret_missing_and_auth_enabled(monkeypatch):
"""With auth on, an unconfigured secret must refuse unsigned processing."""
import navi.api.routes.webhooks as webhooks_mod
new_settings = Settings(
_env_file=None,
navi_persona_file="",
gnauth_client_id="cid",
gnauth_client_secret="csecret",
gnauth_webhook_secret="",
navi_auth_enabled=True,
)
monkeypatch.setattr(webhooks_mod, "settings", new_settings)
from navi.main import app
tc = TestClient(app)
resp = _post(tc, json.dumps({"type": "auth.global_logout"}))
assert resp.status_code == 503
def test_webhook_accepts_unsigned_when_auth_disabled(monkeypatch):
"""Without auth (trusted local mode) unsigned webhooks are accepted with a warning."""
import navi.api.routes.webhooks as webhooks_mod
new_settings = Settings(
_env_file=None,
navi_persona_file="",
gnauth_client_id="cid",
gnauth_client_secret="csecret",
gnauth_webhook_secret="",
navi_auth_enabled=False,
)
monkeypatch.setattr(webhooks_mod, "settings", new_settings)
monkeypatch.setattr(webhooks_mod, "get_gauth_client", _make_gauth_client)
invalidate = AsyncMock()
monkeypatch.setattr(
"navi.api.routes.webhooks._invalidate_user_sessions", invalidate
)
from navi.main import app
tc = TestClient(app)
resp = _post(tc, json.dumps({"type": "user.blocked", "target": {"user_id": "u9"}}))
assert resp.status_code == 200
assert resp.json() == {"ok": True}
invalidate.assert_awaited_once_with("u9")