from __future__ import annotations import hashlib import hmac import json import pytest from fastapi.testclient import TestClient import app.main as main import app.routes as routes from tools.rag.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_startup_survives_embedding_warmup_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: def broken_embedding( *args, **kwargs, ): raise RuntimeError("embedding warmup failed") monkeypatch.setattr( main, "embed_query", broken_embedding, ) with TestClient(main.app) as test_client: response = test_client.get("/health") assert response.status_code == 200 def test_health_endpoint( client: TestClient, ) -> None: response = client.get("/health") assert response.status_code == 200 payload = response.json() assert payload["status"] == "ok" assert isinstance( payload["ready"], bool, ) 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( routes, "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 payload = response.json() assert payload["count"] == 1 assert payload["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_whitespace_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_unknown_field( client: TestClient, ) -> None: response = client.post( "/search", headers={ "X-API-Key": (SEARCH_API_KEY), }, json={ "query": "Ján Holp", "unknown_field": True, }, ) 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_search_returns_400_for_invalid_search( client: TestClient, monkeypatch: pytest.MonkeyPatch, ) -> None: def invalid_search( *args, **kwargs, ): raise ValueError("Neplatný vyhľadávací dotaz") monkeypatch.setattr( routes, "search_database", invalid_search, ) response = client.post( "/search", headers={ "X-API-Key": (SEARCH_API_KEY), }, json={ "query": "test", }, ) assert response.status_code == 400 def test_search_returns_503_when_index_is_missing( client: TestClient, monkeypatch: pytest.MonkeyPatch, ) -> None: def missing_index( *args, **kwargs, ): raise FileNotFoundError("/private/path/zp_index.sqlite") monkeypatch.setattr( routes, "search_database", missing_index, ) response = client.post( "/search", headers={ "X-API-Key": (SEARCH_API_KEY), }, json={ "query": "test", }, ) assert response.status_code == 503 assert response.json()["detail"] == (routes.RETRIEVAL_UNAVAILABLE_DETAIL) assert "/private/path" not in response.text def test_search_returns_generic_500_for_unexpected_error( client: TestClient, monkeypatch: pytest.MonkeyPatch, ) -> None: def broken_search( *args, **kwargs, ): raise TypeError("private internal error") monkeypatch.setattr( routes, "search_database", broken_search, ) response = client.post( "/search", headers={ "X-API-Key": (SEARCH_API_KEY), }, json={ "query": "test", }, ) assert response.status_code == 500 assert response.json()["detail"] == (routes.INTERNAL_ERROR_DETAIL) assert "private internal error" not in response.text 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, monkeypatch): monkeypatch.setattr(main, "rebuild_index", lambda **kw: fake_rebuild_result()) response = client.post("/sync", headers={"X-API-Key": SYNC_API_KEY}, json={"pull_git": False}) assert response.status_code == 202 assert response.json()["status"] == "accepted" assert main.app.state.sync_jobs.run_once() result = client.get(response.json()["status_url"], headers={"X-API-Key": SYNC_API_KEY}) assert result.json()["status"] == "succeeded" assert result.json()["result"] == fake_rebuild_result() def test_sync_waits_when_reindex_is_running(client, monkeypatch): def busy(**kw): raise ReindexInProgressError("Reindexovanie už prebieha") monkeypatch.setattr(main, "rebuild_index", busy) response = client.post("/sync", headers={"X-API-Key": SYNC_API_KEY}, json={}) assert response.status_code == 202 main.app.state.sync_jobs.run_once() job = client.get(response.json()["status_url"], headers={"X-API-Key": SYNC_API_KEY}).json() assert job["status"] == "queued" and job["phase"] == "waiting_for_lock" def test_sync_records_failure_without_exposing_private_error(client, monkeypatch): def fail(**kw): raise RuntimeError("private rebuild error") monkeypatch.setattr(main, "rebuild_index", fail) response = client.post("/sync", headers={"X-API-Key": SYNC_API_KEY}, json={}) assert response.status_code == 202 queue = main.app.state.sync_jobs for _ in range(3): with queue.connect() as conn: conn.execute("UPDATE jobs SET available_at=0") queue.run_once() status = client.get(response.json()["status_url"], headers={"X-API-Key": SYNC_API_KEY}) assert status.json()["status"] == "failed" assert status.json()["reason"] == "sync_failed" assert "private rebuild error" not in status.text def test_webhook_rejects_invalid_signature( client: TestClient, ) -> None: body = json.dumps( { "ref": "refs/heads/master", "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( { "ref": "refs/heads/master", "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( { "ref": "refs/heads/master", "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( { "ref": "refs/heads/master", "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( { "ref": "refs/heads/master", "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 == 202 payload = response.json() assert payload["verified_by"] == "hmac_sha256" assert payload["repository"] == "KEMT/zpwiki" def test_webhook_queues_while_reindex_is_running(client, monkeypatch): def busy(**kw): raise ReindexInProgressError("Reindexovanie už prebieha") monkeypatch.setattr(main, "rebuild_index", busy) body = json.dumps( {"repository": {"full_name": "KEMT/zpwiki"}, "ref": "refs/heads/master"} ).encode() response = client.post( "/webhook/gitea", content=body, headers={"X-Gitea-Event": "push", "X-Gitea-Signature": sign(body)}, ) assert response.status_code == 202 main.app.state.sync_jobs.run_once() job = main.app.state.sync_jobs.get(response.json()["job_id"]) assert job["status"] == "queued" and job["phase"] == "waiting_for_lock" def test_webhook_rebuild_failure_is_visible_in_job(client, monkeypatch): def fail(**kw): raise RuntimeError("private webhook error") monkeypatch.setattr(main, "rebuild_index", fail) body = json.dumps( {"repository": {"full_name": "KEMT/zpwiki"}, "ref": "refs/heads/master"} ).encode() response = client.post( "/webhook/gitea", content=body, headers={"X-Gitea-Event": "push", "X-Gitea-Signature": sign(body)}, ) assert response.status_code == 202 main.app.state.sync_jobs.run_once() status = client.get(response.json()["status_url"], headers={"X-API-Key": SYNC_API_KEY}) assert status.json()["phase"] == "retry_wait" assert "private webhook error" not in status.text