diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7464b7..50d008df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/reference/explorer.md b/docs/reference/explorer.md index 578ca316..e22fc7e7 100644 --- a/docs/reference/explorer.md +++ b/docs/reference/explorer.md @@ -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`) | diff --git a/tests/explorer/test_sparql_route.py b/tests/explorer/test_sparql_route.py new file mode 100644 index 00000000..3be8deee --- /dev/null +++ b/tests/explorer/test_sparql_route.py @@ -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 ?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 \"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 ?label } " + "WHERE { ?s ?label }", + ) + assert resp.status_code == 200 + payload = resp.json() + assert payload["error"] is None + + +def test_describe_query_succeeds(client): + resp = _post(client, "DESCRIBE ") + 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 }") + 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 }") + 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 { a }", + "DELETE DATA { a }", + "DELETE WHERE { ?s ?p ?o }", + "DROP ALL", + "DROP GRAPH ", + "CLEAR ALL", + "CLEAR GRAPH ", + "LOAD ", + "CREATE GRAPH ", + "MODIFY 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 }", + ) + 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 }") + 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 }") + 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 { ?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 { " + " ?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 }") + + 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