dp-zp-agent/test/test_api.py

823 lines
16 KiB
Python

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