680 lines
12 KiB
Python
680 lines
12 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 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
|
|
)
|