diff --git a/tests/test_sparql_plugin.py b/tests/test_sparql_plugin.py index 769ee800..66048b7f 100644 --- a/tests/test_sparql_plugin.py +++ b/tests/test_sparql_plugin.py @@ -86,6 +86,33 @@ def test_sparql_fetch_http_error(): source.fetch("https://example.test/sparql", "SELECT * WHERE { }") +def test_sparql_fetch_post_403_falls_back_to_get(): + """POST 403 (WAF/Virtuoso) deve cadere sul fallback GET. + + Regressione: il plugin considerava ok il 403 (is_ok = response presente, + err None) e salvava il body HTML come CSV — il fallback GET non scattava. + Caso reale: dati.senato.it accetta solo GET (POST → 403). + """ + with patch("toolkit.plugins.sparql.HttpClient") as mock_cls: + mock_cls.return_value.post.return_value = _http_ok( + status=403, + text="Forbidden", + ) + mock_cls.return_value.get.return_value = _http_ok( + status=200, + text="name,value\nfoo,123\n", + headers={"Content-Type": "text/csv"}, + ) + source = SparqlSource() + payload, origin = source.fetch( + "https://dati.senato.it/sparql", + "SELECT ?name ?value WHERE { }", + accept_format="csv", + ) + assert b"foo" in payload + assert mock_cls.return_value.get.called # il fallback GET è scattato + + def test_sparql_fetch_network_error(): """Network error raises DownloadError.""" with patch("toolkit.plugins.sparql.HttpClient") as mock_cls: diff --git a/toolkit/plugins/sparql.py b/toolkit/plugins/sparql.py index 94d02d5c..de749fc5 100644 --- a/toolkit/plugins/sparql.py +++ b/toolkit/plugins/sparql.py @@ -48,20 +48,39 @@ def _do_fetch(self, endpoint: str, q: str, accept_format: str) -> bytes: headers=headers, retries=2, ) - if result.is_ok: + # is_ok è True anche su errori HTTP (response presente, err None) — + # ma una risposta 4xx/5xx NON è un risultato SPARQL valido. + # Fallback GET solo su 4xx (WAF/Virtuoso rifiutano POST): 403 è il + # caso tipico. Su 5xx il fallback non deve mascherare l'errore server. + post_status = ( + result.response.status_code if result.is_ok and result.response is not None else None + ) + if post_status is not None and post_status < 400: return self._parse_response(result.response, is_json) - # --- Tentativo 2: GET fallback --- - url = f"{endpoint}?query={urllib.parse.quote(q)}" - get_headers = { - "Accept": ( - "application/sparql-results+xml," - "application/sparql-results+json,application/json,text/csv" - ), - } - result = self._client.get(url, headers=get_headers) - if result.is_ok: - return self._parse_response(result.response, is_json) + # --- Tentativo 2: GET fallback (solo se POST è fallito con 4xx) --- + if post_status is not None and 400 <= post_status < 500: + url = f"{endpoint}?query={urllib.parse.quote(q)}" + get_headers = { + "Accept": ( + "application/sparql-results+xml," + "application/sparql-results+json,application/json,text/csv" + ), + } + result = self._client.get(url, headers=get_headers) + get_status = ( + result.response.status_code + if result.is_ok and result.response is not None + else None + ) + if get_status is not None and get_status < 400: + return self._parse_response(result.response, is_json) + + if post_status is not None and post_status >= 500: + body = (result.response.text or "")[:200] if result.response is not None else "" + raise DownloadError( + f"SPARQL endpoint returned HTTP {post_status} for {endpoint}: {body}" + ) raise DownloadError( f"SPARQL request failed for {endpoint}: POST → {result.err or 'unknown'}"