mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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.
267 lines
10 KiB
Python
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
|