This commit is contained in:
Ján Pták 2026-08-14 23:46:07 +02:00
parent ba0bb1c8b9
commit a808714876

598
test/test_security.py Normal file
View File

@ -0,0 +1,598 @@
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
import app.security as security
WEBHOOK_SECRET = "w" * 64
SYNC_API_KEY = "s" * 64
SEARCH_API_KEY = "a" * 64
EXPECTED_REPOSITORY = (
"KEMT/zpwiki"
)
def sign(
body: bytes,
) -> str:
return hmac.new(
WEBHOOK_SECRET.encode(
"utf-8"
),
body,
hashlib.sha256,
).hexdigest()
@pytest.fixture
def client(
security_environment,
) -> TestClient:
with TestClient(
main.app
) as test_client:
yield test_client
def test_security_configuration_accepts_valid_configuration(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv(
"WEBHOOK_SECRET",
WEBHOOK_SECRET,
)
monkeypatch.setenv(
"SYNC_API_KEY",
SYNC_API_KEY,
)
monkeypatch.setenv(
"SEARCH_API_KEY",
SEARCH_API_KEY,
)
monkeypatch.setenv(
"EXPECTED_GITEA_REPOSITORY",
EXPECTED_REPOSITORY,
)
monkeypatch.setenv(
"WEBHOOK_PULL_GIT",
"false",
)
security.validate_security_configuration()
def test_security_configuration_rejects_shared_secrets(
monkeypatch: pytest.MonkeyPatch,
) -> None:
shared = "x" * 64
monkeypatch.setenv(
"WEBHOOK_SECRET",
shared,
)
monkeypatch.setenv(
"SYNC_API_KEY",
shared,
)
monkeypatch.setenv(
"SEARCH_API_KEY",
SEARCH_API_KEY,
)
monkeypatch.setenv(
"EXPECTED_GITEA_REPOSITORY",
EXPECTED_REPOSITORY,
)
with pytest.raises(
RuntimeError,
match="navzájom rozdielne",
):
security.validate_security_configuration()
@pytest.mark.parametrize(
"value",
[
"zpwiki",
"/zpwiki",
"KEMT/",
"KEMT/zpwiki/extra",
"KEMT /zpwiki",
"KEMT/zp wiki",
],
)
def test_expected_repository_rejects_invalid_format(
monkeypatch: pytest.MonkeyPatch,
value: str,
) -> None:
monkeypatch.setenv(
"EXPECTED_GITEA_REPOSITORY",
value,
)
with pytest.raises(
RuntimeError,
match="vlastník/repozitár",
):
security.expected_gitea_repository()
@pytest.mark.parametrize(
(
"value",
"expected",
),
[
("true", True),
("TRUE", True),
("1", True),
("yes", True),
("on", True),
("false", False),
("FALSE", False),
("0", False),
("no", False),
("off", False),
],
)
def test_webhook_pull_git_boolean_values(
monkeypatch: pytest.MonkeyPatch,
value: str,
expected: bool,
) -> None:
monkeypatch.setenv(
"WEBHOOK_PULL_GIT",
value,
)
assert (
security.webhook_should_pull_git()
is expected
)
def test_webhook_pull_git_rejects_invalid_value(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv(
"WEBHOOK_PULL_GIT",
"maybe",
)
with pytest.raises(
RuntimeError,
match="boolean",
):
security.webhook_should_pull_git()
def test_repository_payload_requires_full_name() -> None:
payload = {
"repository": {
"name": "zpwiki",
}
}
assert (
security.repository_name_from_payload(
payload
)
is None
)
def test_repository_payload_accepts_full_name() -> None:
payload = {
"repository": {
"full_name": (
"KEMT/zpwiki"
),
}
}
assert (
security.repository_name_from_payload(
payload
)
== "KEMT/zpwiki"
)
def test_repository_comparison_is_case_insensitive() -> None:
assert (
security.same_repository(
"kemt/ZPWIKI",
"KEMT/zpwiki",
)
is True
)
def test_gitea_signature_accepts_valid_digest() -> None:
body = b'{"test":true}'
signature = sign(
body
)
assert (
security.verify_gitea_signature(
body,
signature,
WEBHOOK_SECRET,
)
is True
)
def test_gitea_signature_accepts_sha256_prefix() -> None:
body = b'{"test":true}'
signature = (
"sha256="
+ sign(
body
)
)
assert (
security.verify_gitea_signature(
body,
signature,
WEBHOOK_SECRET,
)
is True
)
def test_gitea_signature_rejects_modified_body() -> None:
original = b'{"test":true}'
modified = b'{"test":false}'
signature = sign(
original
)
assert (
security.verify_gitea_signature(
modified,
signature,
WEBHOOK_SECRET,
)
is False
)
@pytest.mark.parametrize(
"signature",
[
None,
"",
"abc",
"x" * 64,
"0" * 63,
"0" * 65,
],
)
def test_gitea_signature_rejects_invalid_signature(
signature: str | None,
) -> None:
assert (
security.verify_gitea_signature(
b"body",
signature,
WEBHOOK_SECRET,
)
is False
)
def test_sync_key_cannot_access_rag(
client: TestClient,
) -> None:
response = client.post(
"/rag",
headers={
"X-API-Key": (
SYNC_API_KEY
),
},
json={
"query": "Ján Holp",
},
)
assert (
response.status_code
== 401
)
def test_search_key_cannot_access_sync(
client: TestClient,
) -> None:
response = client.post(
"/sync",
headers={
"X-API-Key": (
SEARCH_API_KEY
),
},
json={
"pull_git": False,
},
)
assert (
response.status_code
== 401
)
def test_sync_key_cannot_be_used_as_rag_bearer(
client: TestClient,
) -> None:
response = client.post(
"/rag",
headers={
"Authorization": (
f"Bearer {SYNC_API_KEY}"
),
},
json={
"query": "Ján Holp",
},
)
assert (
response.status_code
== 401
)
def test_health_does_not_expose_filesystem_paths(
client: TestClient,
) -> None:
response = client.get(
"/health"
)
assert (
response.status_code
== 200
)
payload = response.json()
assert (
"database_path"
not in payload
)
assert (
"zpwiki_root"
not in payload
)
assert (
"/home/"
not in response.text
)
def test_swagger_docs_are_disabled(
client: TestClient,
) -> None:
assert (
client.get(
"/docs"
).status_code
== 404
)
assert (
client.get(
"/redoc"
).status_code
== 404
)
def test_openapi_schema_remains_available(
client: TestClient,
) -> None:
response = client.get(
"/openapi.json"
)
assert (
response.status_code
== 200
)
assert (
"/rag"
in response.json()[
"paths"
]
)
def test_security_response_headers(
client: TestClient,
) -> None:
response = client.get(
"/health"
)
assert (
response.headers[
"cache-control"
]
== "no-store"
)
assert (
response.headers[
"x-content-type-options"
]
== "nosniff"
)
assert (
response.headers[
"referrer-policy"
]
== "no-referrer"
)
assert (
response.headers[
"x-frame-options"
]
== "DENY"
)
def test_cors_accepts_openwebui_origin(
client: TestClient,
) -> None:
response = client.options(
"/rag",
headers={
"Origin": (
"https://ui.tukekemt.xyz"
),
"Access-Control-Request-Method": (
"POST"
),
"Access-Control-Request-Headers": (
"authorization,"
"content-type,"
"x-api-key"
),
},
)
assert (
response.status_code
== 200
)
assert response.headers[
"access-control-allow-origin"
] == (
"https://ui.tukekemt.xyz"
)
def test_cors_rejects_unknown_origin(
client: TestClient,
) -> None:
response = client.options(
"/rag",
headers={
"Origin": (
"https://evil.example"
),
"Access-Control-Request-Method": (
"POST"
),
"Access-Control-Request-Headers": (
"authorization"
),
},
)
assert (
response.headers.get(
"access-control-allow-origin"
)
!= "https://evil.example"
)
def test_webhook_rejects_oversized_payload(
client: TestClient,
) -> None:
body = (
b"x"
* (
routes.MAX_WEBHOOK_BODY_BYTES
+ 1
)
)
response = client.post(
"/webhook/gitea",
content=body,
headers={
"Content-Type": (
"application/json"
),
"X-Gitea-Event": (
"push"
),
},
)
assert (
response.status_code
== 413
)
def test_webhook_requires_repository_full_name(
client: TestClient,
) -> None:
body = json.dumps(
{
"repository": {
"name": (
"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
== 400
)