from __future__ import annotations import hashlib import hmac import json import pytest from fastapi.testclient import TestClient import app.main as main from scripts.rebuild_index import ReindexInProgressError WEBHOOK_SECRET = "w" * 64 SYNC_API_KEY = "s" * 64 def fake_rebuild_result() -> dict: return { "duration_seconds": 0.01, "counts": { "documents": 1, "chunks": 1, "fts_chunks": 1, "tags": 0, "categories": 0, }, } def sign(body: bytes) -> str: return hmac.new( WEBHOOK_SECRET.encode("utf-8"), body, hashlib.sha256, ).hexdigest() @pytest.fixture def client() -> TestClient: with TestClient(main.app) as test_client: yield test_client def test_startup_rejects_missing_secret( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("WEBHOOK_SECRET") with pytest.raises(RuntimeError, match="WEBHOOK_SECRET"): with TestClient(main.app): pass def test_startup_rejects_short_secret( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("SYNC_API_KEY", "short") with pytest.raises(RuntimeError, match="aspoň 32"): with TestClient(main.app): pass def test_health_endpoint(client: TestClient) -> None: response = client.get("/health") assert response.status_code == 200 payload = response.json() assert payload["status"] == "ok" assert payload["search_engine"] == "sqlite_fts5" assert payload["security_configured"] is True def test_search_endpoint_uses_shared_search_logic( client: TestClient, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( main, "search_database", lambda *args, **kwargs: { "engine": "sqlite_fts5", "strategies": ["all_terms"], "results": [{"chunk_id": "test::0", "published": True}], }, ) response = client.post( "/search", json={"query": "jan ptak", "limit": 5}, ) assert response.status_code == 200 assert response.json()["count"] == 1 assert response.json()["results"][0]["chunk_id"] == "test::0" def test_search_rejects_empty_query(client: TestClient) -> None: response = client.post("/search", json={"query": ""}) assert response.status_code == 422 def test_sync_rejects_missing_api_key(client: TestClient) -> None: response = client.post("/sync", json={"pull_git": False}) assert response.status_code == 401 def test_sync_rejects_wrong_api_key(client: TestClient) -> None: response = client.post( "/sync", headers={"X-API-Key": "x" * 64}, json={"pull_git": False}, ) assert response.status_code == 401 def test_sync_accepts_valid_api_key( client: TestClient, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( main, "rebuild_index", lambda pull_git=False: fake_rebuild_result(), ) response = client.post( "/sync", headers={"X-API-Key": SYNC_API_KEY}, json={"pull_git": False}, ) assert response.status_code == 200 assert response.json()["status"] == "ok" def test_sync_returns_conflict_when_reindex_is_running( client: TestClient, monkeypatch: pytest.MonkeyPatch, ) -> None: def busy(*args, **kwargs): raise ReindexInProgressError("Reindexovanie už prebieha") monkeypatch.setattr(main, "rebuild_index", busy) response = client.post( "/sync", headers={"X-API-Key": SYNC_API_KEY}, json={"pull_git": False}, ) assert response.status_code == 409 def test_webhook_rejects_invalid_signature(client: TestClient) -> None: body = json.dumps( {"repository": {"full_name": "KEMT/zpwiki"}} ).encode("utf-8") response = client.post( "/webhook/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "push", "X-Gitea-Signature": "0" * 64, }, ) assert response.status_code == 401 def test_webhook_rejects_invalid_json_with_valid_signature( client: TestClient, ) -> None: body = b"not-json" response = client.post( "/webhook/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "push", "X-Gitea-Signature": sign(body), }, ) assert response.status_code == 400 def test_webhook_requires_event_header(client: TestClient) -> None: body = json.dumps( {"repository": {"full_name": "KEMT/zpwiki"}} ).encode("utf-8") response = client.post( "/webhook/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Signature": sign(body), }, ) assert response.status_code == 400 def test_webhook_ignores_non_push_event( client: TestClient, monkeypatch: pytest.MonkeyPatch, ) -> None: calls = 0 def fake_rebuild(*args, **kwargs): nonlocal calls calls += 1 return fake_rebuild_result() monkeypatch.setattr(main, "rebuild_index", fake_rebuild) body = json.dumps( {"repository": {"full_name": "KEMT/zpwiki"}} ).encode("utf-8") response = client.post( "/webhook/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "issues", "X-Gitea-Signature": sign(body), }, ) assert response.status_code == 202 assert response.json()["status"] == "ignored" assert calls == 0 def test_webhook_rejects_unexpected_repository(client: TestClient) -> None: body = json.dumps( {"repository": {"full_name": "OTHER/repository"}} ).encode("utf-8") response = client.post( "/webhook/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "push", "X-Gitea-Signature": sign(body), }, ) assert response.status_code == 403 def test_webhook_accepts_signed_push( client: TestClient, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( main, "rebuild_index", lambda pull_git=False: fake_rebuild_result(), ) body = json.dumps( {"repository": {"full_name": "KEMT/zpwiki"}} ).encode("utf-8") response = client.post( "/webhook/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "push", "X-Gitea-Signature": sign(body), }, ) assert response.status_code == 200 assert response.json()["verified_by"] == "hmac_sha256" assert response.json()["repository"] == "KEMT/zpwiki" def test_webhook_returns_conflict_when_reindex_is_running( client: TestClient, monkeypatch: pytest.MonkeyPatch, ) -> None: def busy(*args, **kwargs): raise ReindexInProgressError("Reindexovanie už prebieha") monkeypatch.setattr(main, "rebuild_index", busy) body = json.dumps( {"repository": {"full_name": "KEMT/zpwiki"}} ).encode("utf-8") response = client.post( "/webhook/gitea", content=body, headers={ "Content-Type": "application/json", "X-Gitea-Event": "push", "X-Gitea-Signature": sign(body), }, ) assert response.status_code == 409