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 SEARCH_API_KEY = "a" * 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"] == "hybrid_fts5_embeddings" 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", headers={ "X-API-Key": SEARCH_API_KEY, }, 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", headers={ "X-API-Key": SEARCH_API_KEY, }, json={ "query": "", }, ) assert response.status_code == 422 def test_search_rejects_missing_api_key( client: TestClient, ) -> None: response = client.post( "/search", json={ "query": "jan ptak", "limit": 5, }, ) assert response.status_code == 401 def test_search_rejects_wrong_api_key( client: TestClient, ) -> None: response = client.post( "/search", headers={ "X-API-Key": "x" * 64, }, json={ "query": "jan ptak", "limit": 5, }, ) assert response.status_code == 401 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