Files
semantica/tests/explorer/test_sparql_route.py
T
Sameer6305 dd7b090aec test(explorer): add coverage for SPARQL route (#773)
sparql.py handles direct SPARQL query execution against the live graph with no test coverage anywhere in the repo. Adds coverage for the read-only allowlist (the actual security boundary here), row/timeout limits, error handling, and RDF projection fidelity.
2026-07-27 15:00:47 +05:30

267 lines
10 KiB
Python

"""Tests for the SPARQL Explorer route (semantica/explorer/routes/sparql.py).
Filed as #773: this route executes arbitrary SPARQL against an in-memory
rdflib projection of the live graph and had zero test coverage anywhere in
the repo. Coverage here focuses on the two things that matter most for a
query-execution surface: (1) the read-only allowlist can't be bypassed, and
(2) the resource-limiting behavior (row cap, timeout) actually engages.
"""
import asyncio
from unittest.mock import patch
import pytest
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.session import GraphSession
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip(
"starlette TestClient is required for explorer tests. Install semantica[explorer].",
allow_module_level=True,
)
import semantica.explorer.routes.sparql as sparql_mod
def _build_sample_graph() -> ContextGraph:
graph = ContextGraph(advanced_analytics=False)
graph.add_node(
"python",
node_type="language",
content="Python programming language",
popularity="high",
)
graph.add_node("javascript", node_type="language", content="JavaScript programming language")
graph.add_node("web_dev", node_type="concept", content="Web Development")
graph.add_edge("python", "web_dev", edge_type="used_in", weight=0.5)
return graph
@pytest.fixture(scope="module")
def client():
session = GraphSession(_build_sample_graph())
app = create_app(session=session)
with TestClient(app) as test_client:
yield test_client
def _post(client, query):
return client.post("/api/sparql", json={"query": query})
# ---------------------------------------------------------------------------
# Happy-path query types
# ---------------------------------------------------------------------------
def test_select_returns_expected_columns_and_rows(client):
resp = _post(client, "SELECT ?s ?label WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label }")
assert resp.status_code == 200
payload = resp.json()
assert payload["error"] is None
assert set(payload["columns"]) == {"s", "label"}
assert payload["total"] == len(payload["rows"])
labels = {row["label"] for row in payload["rows"]}
assert "Python programming language" in labels
assert "JavaScript programming language" in labels
def test_ask_query_returns_boolean_like_result(client):
resp = _post(
client,
"ASK { ?s <http://www.w3.org/2000/01/rdf-schema#label> \"Python programming language\" }",
)
assert resp.status_code == 200
payload = resp.json()
assert payload["error"] is None
def test_construct_query_succeeds(client):
resp = _post(
client,
"CONSTRUCT { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label } "
"WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label }",
)
assert resp.status_code == 200
payload = resp.json()
assert payload["error"] is None
def test_describe_query_succeeds(client):
resp = _post(client, "DESCRIBE <http://semantica.local/entity/python>")
assert resp.status_code == 200
payload = resp.json()
assert payload["error"] is None
def test_lowercase_query_keyword_is_accepted(client):
"""The allowlist regex is case-insensitive; confirm lowercase keywords work too."""
resp = _post(client, "select ?s where { ?s a <http://semantica.local/entity/language> }")
assert resp.status_code == 200
payload = resp.json()
assert payload["error"] is None
def test_select_with_no_results_returns_empty_rows(client):
resp = _post(client, "SELECT ?s WHERE { ?s a <http://semantica.local/entity/nonexistent> }")
assert resp.status_code == 200
payload = resp.json()
assert payload["error"] is None
assert payload["rows"] == []
assert payload["total"] == 0
# ---------------------------------------------------------------------------
# Read-only allowlist: this is the security-relevant surface (#773's core risk)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"query",
[
"INSERT DATA { <http://semantica.local/entity/x> a <http://semantica.local/entity/hacked> }",
"DELETE DATA { <http://semantica.local/entity/python> a <http://semantica.local/entity/language> }",
"DELETE WHERE { ?s ?p ?o }",
"DROP ALL",
"DROP GRAPH <http://example.org/g>",
"CLEAR ALL",
"CLEAR GRAPH <http://example.org/g>",
"LOAD <http://example.org/data.ttl>",
"CREATE GRAPH <http://example.org/g>",
"MODIFY <http://example.org/g> DELETE { ?s ?p ?o } WHERE { ?s ?p ?o }",
"",
" ",
"not a sparql query at all",
# A write statement smuggled after a comment/whitespace prefix, or
# appended after a valid-looking read query, must still be rejected
# since the whole string doesn't start with an allowed keyword.
"# comment\nDROP ALL",
"SELECT ?s WHERE { ?s ?p ?o } ; DROP ALL",
],
)
def test_write_and_non_read_queries_are_rejected(client, query):
resp = _post(client, query)
assert resp.status_code == 200, "rejection is a normal 200 response with an error field, not an HTTP error"
payload = resp.json()
assert payload["error"] is not None
assert payload["rows"] == []
assert payload["columns"] == []
assert payload["total"] == 0
def test_rejected_query_never_touches_the_graph(client):
"""A rejected query must short-circuit before any graph is built/queried."""
with patch.object(sparql_mod, "_build_rdflib_graph") as mock_build:
resp = _post(client, "DROP ALL")
assert resp.status_code == 200
assert resp.json()["error"] is not None
mock_build.assert_not_called()
# ---------------------------------------------------------------------------
# Error handling for malformed queries
# ---------------------------------------------------------------------------
def test_malformed_query_returns_error_without_crashing(client):
resp = _post(client, "SELECT ?s WHERE { this is not valid sparql syntax")
assert resp.status_code == 200
payload = resp.json()
assert payload["error"] is not None
assert payload["rows"] == []
assert payload["total"] == 0
def test_malformed_query_error_includes_line_and_column_when_present(client):
resp = _post(client, "SELECT ?s WHERE { $$$ invalid $$$ }")
payload = resp.json()
assert payload["error"] is not None
# error_line/error_column are best-effort extraction from the pyparsing
# error message; they may be None depending on rdflib's error text, but
# the fields must always be present and of the right type when set.
assert payload["error_line"] is None or isinstance(payload["error_line"], int)
assert payload["error_column"] is None or isinstance(payload["error_column"], int)
# ---------------------------------------------------------------------------
# Resource limits: row cap and timeout
# ---------------------------------------------------------------------------
def test_row_cap_truncates_results(client):
with patch.object(sparql_mod, "_SPARQL_MAX_ROWS", 1):
resp = _post(
client,
"SELECT ?s WHERE { ?s a <http://semantica.local/entity/language> }",
)
assert resp.status_code == 200
payload = resp.json()
assert payload["error"] is None
assert len(payload["rows"]) == 1
assert payload["truncated"] is True
def test_result_below_cap_is_not_marked_truncated(client):
resp = _post(client, "SELECT ?s WHERE { ?s a <http://semantica.local/entity/language> }")
payload = resp.json()
assert payload["truncated"] is False
def test_query_timeout_returns_clean_error_not_a_crash(client):
async def _raise_timeout(coro, timeout=None):
coro.close() # avoid a 'coroutine was never awaited' warning from the mock
raise asyncio.TimeoutError()
with patch.object(sparql_mod.asyncio, "wait_for", side_effect=_raise_timeout):
resp = _post(client, "SELECT ?s WHERE { ?s a <http://semantica.local/entity/language> }")
assert resp.status_code == 200
payload = resp.json()
assert payload["error"] is not None
assert "timed out" in payload["error"].lower()
assert payload["rows"] == []
# ---------------------------------------------------------------------------
# Data-mapping fidelity: does the graph->RDF projection reflect session state?
# ---------------------------------------------------------------------------
def test_node_properties_are_projected_as_literals_excluding_reserved_keys(client):
resp = _post(
client,
"SELECT ?p ?v WHERE { <http://semantica.local/entity/python> ?p ?v }",
)
payload = resp.json()
predicates = {row["p"] for row in payload["rows"]}
# 'popularity' is a real property and should be projected.
assert any("popularity" in p for p in predicates)
# content/valid_from/valid_until are excluded from prop: projection
# (content instead becomes rdfs:label, handled separately).
assert not any(p.endswith("/prop/content") for p in predicates)
def test_edges_are_projected_with_their_relationship_type(client):
resp = _post(
client,
"SELECT ?o WHERE { <http://semantica.local/entity/python> "
"<http://semantica.local/prop/used_in> ?o }",
)
payload = resp.json()
assert payload["error"] is None
assert any("web_dev" in row["o"] for row in payload["rows"])
def test_concurrent_requests_all_complete_successfully(client):
"""Basic smoke test that the concurrency semaphore doesn't deadlock or
drop requests under light concurrent load."""
import concurrent.futures
def _run():
return _post(client, "SELECT ?s WHERE { ?s a <http://semantica.local/entity/language> }")
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
results = list(pool.map(lambda _: _run(), range(6)))
for resp in results:
assert resp.status_code == 200
assert resp.json()["error"] is None