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 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_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: TestClient, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( routes, "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( routes, "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_sync_returns_generic_500_when_rebuild_fails( client: TestClient, monkeypatch: pytest.MonkeyPatch, ) -> None: def failed_rebuild( *args, **kwargs, ): raise RuntimeError( "private rebuild error" ) monkeypatch.setattr( routes, "rebuild_index", failed_rebuild, ) response = client.post( "/sync", headers={ "X-API-Key": ( SYNC_API_KEY ), }, json={ "pull_git": False, }, ) assert ( response.status_code == 500 ) assert response.json()[ "detail" ] == ( routes.REINDEX_FAILED_DETAIL ) assert ( "private rebuild error" not in response.text ) 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( routes, "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( routes, "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 ) payload = response.json() assert ( payload[ "verified_by" ] == "hmac_sha256" ) assert ( payload[ "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( routes, "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 ) def test_webhook_returns_generic_500_when_rebuild_fails( client: TestClient, monkeypatch: pytest.MonkeyPatch, ) -> None: def failed_rebuild( *args, **kwargs, ): raise RuntimeError( "private webhook error" ) monkeypatch.setattr( routes, "rebuild_index", failed_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": ( "push" ), "X-Gitea-Signature": ( sign( body ) ), }, ) assert ( response.status_code == 500 ) assert response.json()[ "detail" ] == ( routes.REINDEX_FAILED_DETAIL ) assert ( "private webhook error" not in response.text )