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_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( 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 ) 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( 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_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 ) 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( 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 )