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