Newer
Older
navi-1 / tests / unit / api / test_webhooks.py
"""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")