dp-zp-agent/test/test_api.py

457 lines
9.1 KiB
Python

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"] == "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",
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