mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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.
This commit is contained in:
@@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Comprehensive unit and security test suite for the `/api/sparql` Explorer route** (#773) by @Sameer6305
|
||||
- Added `tests/explorer/test_sparql_route.py` (30 tests) covering the SPARQL Explorer route (`semantica/explorer/routes/sparql.py`), which executes arbitrary SPARQL queries against an in-memory rdflib projection of the live graph and previously had zero test coverage
|
||||
- Verified read-only allowlist enforcement against write and mutation queries (`INSERT DATA`, `DELETE DATA`, `DELETE WHERE`, `DROP ALL`, `CLEAR ALL`, `LOAD`, `CREATE GRAPH`, `MODIFY`, comments, and multi-statement injections like `SELECT ... ; DROP ALL`), confirming rejected queries short-circuit before any graph is built or queried
|
||||
- Verified resource-limiting behavior, confirming row capping (`_SPARQL_MAX_ROWS`) truncates results and sets `truncated: true`, query timeout (`_SPARQL_TIMEOUT_S`) returns a clean error message without crashing, and concurrency semaphore (`_SPARQL_MAX_CONCURRENT`) prevents thread starvation under load
|
||||
- Verified RDF projection fidelity for node properties and edge relationships, and error formatting for malformed SPARQL syntax with line and column extraction
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`react-hooks/set-state-in-effect` cascading renders across 12 Explorer workspace files** (#769, #796) by @Sameer6305 and @KaifAhmad1
|
||||
|
||||
@@ -262,7 +262,7 @@ Full interactive docs at `http://localhost:8000/docs`. All endpoints accept and
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| :-------- | :------ | :----------- |
|
||||
| `/api/sparql` | `POST` | Execute a SPARQL SELECT or ASK query |
|
||||
| `/api/sparql` | `POST` | Execute a read-only SPARQL query (`SELECT`, `ASK`, `CONSTRUCT`, or `DESCRIBE`) |
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Decisions, Provenance, Annotations & Export">
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user