""" Offline unit tests for /query/graph hardening. The raw Cypher endpoint must: - reject queries containing write clauses or CALL procedures (denylist), - execute allowed queries through the READ-ONLY client path (Neo4jClient.execute_read), never the writable execute_query path. """ from unittest.mock import AsyncMock, MagicMock import pytest from src.services.graph_service import GraphService @pytest.fixture def mock_neo4j(): neo4j = MagicMock() neo4j.execute_read = AsyncMock(return_value=[{"n": {"name": "x"}}]) neo4j.execute_query = AsyncMock(return_value=[{"n": {"name": "x"}}]) return neo4j @pytest.fixture def service(mock_neo4j): return GraphService(neo4j_client=mock_neo4j, wikijs_client=MagicMock()) WRITE_QUERIES = [ "CREATE (n:Evil) RETURN n", "MATCH (n) DELETE n", "MATCH (n) DETACH DELETE n", "MERGE (n:Evil {name: 'x'}) RETURN n", "MATCH (n) SET n.pwned = true RETURN n", "MATCH (n) REMOVE n:Document RETURN n", "DROP INDEX my_index", "FOREACH (x IN [1] | CREATE (:Evil))", "LOAD CSV FROM 'file:///etc/passwd' AS row RETURN row", "CALL db.labels()", "CALL apoc.periodic.iterate('MATCH (n) RETURN n', 'DELETE n', {})", "call dbms.components()", "match (n) detach delete n", # lowercase "MATCH (n)\nSET n.x = 1", # multiline ] @pytest.mark.unit class TestWriteClauseDenylist: """Write clauses and procedure calls must be rejected before execution.""" @pytest.mark.parametrize("query", WRITE_QUERIES) async def test_write_query_rejected(self, service, mock_neo4j, query): with pytest.raises(ValueError, match="read-only"): await service.execute_query(query, {}, user="llm_tester") mock_neo4j.execute_read.assert_not_awaited() mock_neo4j.execute_query.assert_not_awaited() async def test_read_query_allowed(self, service): response = await service.execute_query( "MATCH (n:Document) RETURN n LIMIT 5", {}, user="llm_tester" ) assert response.count == 1 async def test_word_boundary_no_false_positive(self, service): """Words merely containing denylisted substrings must pass.""" response = await service.execute_query( "MATCH (n:Document) WHERE n.title = 'dataSET dropped' RETURN n", {}, user="llm_tester", ) assert response.count == 1 @pytest.mark.unit class TestReadOnlyExecution: """Allowed queries must run through the read-only session path.""" async def test_uses_execute_read_not_execute_query(self, service, mock_neo4j): await service.execute_query( "MATCH (n) RETURN n LIMIT 1", {"p": 1}, user="llm_tester" ) mock_neo4j.execute_read.assert_awaited_once_with( "MATCH (n) RETURN n LIMIT 1", {"p": 1} ) mock_neo4j.execute_query.assert_not_awaited() async def test_neo4j_client_read_session_access_mode(self): """Neo4jClient.execute_read must open the session with READ_ACCESS.""" from neo4j import READ_ACCESS from src.clients.neo4j_client import Neo4jClient client = Neo4jClient(uri="bolt://unused:7687", user="u", password="p") session = MagicMock() run_result = MagicMock() run_result.data = AsyncMock(return_value=[{"ok": 1}]) session.run = AsyncMock(return_value=run_result) session_cm = MagicMock() session_cm.__aenter__ = AsyncMock(return_value=session) session_cm.__aexit__ = AsyncMock(return_value=False) driver = MagicMock() driver.session = MagicMock(return_value=session_cm) client._driver = driver records = await client.execute_read("RETURN 1 AS ok") assert records == [{"ok": 1}] driver.session.assert_called_once_with(default_access_mode=READ_ACCESS)