620 lines
14 KiB
Python
620 lines
14 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
|