"""Tests for the SAP OData ingestor.
The SAP connector never touches a live SAP system: every outbound request goes
through ``semantica.ingest.ssrf.request_with_ssrf_guard`` (see
``sap_ingestor.py``), so these tests patch that single entry point and drive
the parser / connector / ingestor with canned responses.
Covered:
- ``$metadata`` (CSDL XML) -> entity set discovery & property fields
- OData v2 atom (``__next``) and OData v4 (``@odata.nextLink``) pagination
- Basic and OAuth2 credential payloads propagated on the request
- SSRF guard is used for data *and* token-exchange requests
- ``export_as_documents`` yields the flat document shape GraphBuilder expects
"""
from unittest.mock import MagicMock, patch
import pytest
import requests
from semantica.ingest import SAPIngestor, SAPODataConnector, SAPODataEntity
from semantica.utils.exceptions import ProcessingError, ValidationError
METADATA_V2 = """
"""
METADATA_V4 = """
"""
METADATA_MULTI_SCHEMA = """
"""
def _fake_response(status_code=200, json_payload=None, text="", headers=None):
"""requests.Response-like stand-in returned by the mocked guard."""
resp = MagicMock()
resp.status_code = status_code
resp.headers = headers or {}
resp.text = text
resp.url = "https://sap.example/odata/$metadata"
if json_payload is not None:
resp.json.return_value = json_payload
else:
resp.json.side_effect = ValueError("not json")
if status_code >= 400:
resp.raise_for_status.side_effect = requests.exceptions.HTTPError(
f"{status_code} error"
)
return resp
def _json_resp(payload, url):
r = MagicMock()
r.status_code = 200
r.headers = {"content-type": "application/json"}
r.text = ""
r.url = url
r.json.return_value = payload
return r
class TestDiscoverService:
def test_metadata_parses_entity_sets_and_fields(self):
resp = MagicMock()
resp.status_code = 200
resp.headers = {"content-type": "application/xml"}
resp.text = METADATA_V2
resp.json.side_effect = ValueError("xml not json")
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
return_value=resp,
) as guard:
ing = SAPIngestor(
base_url="https://sap.example/odata/", username="u", password="p"
)
sets = ing.discover_service()
assert guard.call_count == 1
assert [s["name"] for s in sets] == ["SalesOrderSet", "CustomerSet"]
sales = next(s for s in sets if s["name"] == "SalesOrderSet")
assert {f["name"] for f in sales["fields"]} == {
"SalesOrderID",
"CustomerID",
"GrossAmount",
}
assert sales["fields"][0]["type"] == "Edm.String"
def test_discover_flow_routes_via_ssrf(self):
resp = MagicMock()
resp.status_code = 200
resp.headers = {}
resp.text = METADATA_V2
resp.json.side_effect = ValueError()
with patch("semantica.ingest.sap_ingestor.request_with_ssrf_guard") as guard:
guard.return_value = resp
ing = SAPIngestor(
base_url="https://sap.example/odata/", username="u", password="p"
)
ing.discover_service()
method, url = guard.call_args[0]
assert method == "GET"
assert url.endswith("$metadata")
def test_metadata_url_keeps_last_segment_without_trailing_slash(self):
"""base_url without a trailing '/' must not lose its last segment.
urljoin() replaces the final path segment when the base has no
trailing slash, which would silently turn .../API_BUSINESS_PARTNER
into .../$metadata against the wrong service root.
"""
resp = MagicMock()
resp.status_code = 200
resp.headers = {}
resp.text = METADATA_V2
resp.json.side_effect = ValueError()
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
return_value=resp,
) as guard:
ing = SAPIngestor(
base_url="https://sap.example/odata/API_BUSINESS_PARTNER",
username="u",
password="p",
)
ing.discover_service()
method, url = guard.call_args[0]
assert url == "https://sap.example/odata/API_BUSINESS_PARTNER/$metadata"
def test_service_root_appends_metadata(self):
"""service is a service root (same meaning as ingest_entity_set):
$metadata is appended automatically, not treated as the full path.
"""
resp = MagicMock()
resp.status_code = 200
resp.headers = {}
resp.text = METADATA_V2
resp.json.side_effect = ValueError()
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
return_value=resp,
) as guard:
ing = SAPIngestor(
base_url="https://sap.example/odata/sap/",
username="u",
password="p",
)
ing.discover_service("API_BUSINESS_PARTNER")
_, url = guard.call_args[0]
assert url == "https://sap.example/odata/sap/API_BUSINESS_PARTNER/$metadata"
def test_absolute_service_root_appends_metadata(self):
resp = MagicMock()
resp.status_code = 200
resp.headers = {}
resp.text = METADATA_V2
resp.json.side_effect = ValueError()
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
return_value=resp,
) as guard:
ing = SAPIngestor(
base_url="https://sap.example/odata/sap/",
username="u",
password="p",
)
ing.discover_service("https://other.example/services/sap/")
_, url = guard.call_args[0]
assert url == "https://other.example/services/sap/$metadata"
def test_v4_oasis_metadata_parses_with_nullable_defaults(self):
resp = _fake_response(
text=METADATA_V4, headers={"content-type": "application/xml"}
)
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
return_value=resp,
):
ing = SAPIngestor(
base_url="https://sap.example/odata/",
username="u",
password="p",
)
sets = ing.discover_service()
by_name = {s["name"]: s for s in sets}
assert "SalesOrders" in by_name and "Customers" in by_name
sales_fields = {f["name"]: f for f in by_name["SalesOrders"]["fields"]}
# Omitted Nullable -> nullable (CSDL default); explicit "false" honored.
assert sales_fields["ID"]["nullable"] is True
assert sales_fields["Total"]["nullable"] is False
def test_same_named_types_in_different_schemas_do_not_merge(self):
resp = _fake_response(
text=METADATA_MULTI_SCHEMA, headers={"content-type": "application/xml"}
)
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
return_value=resp,
):
ing = SAPIngestor(
base_url="https://sap.example/odata/",
username="u",
password="p",
)
sets = ing.discover_service()
by_name = {s["name"]: s for s in sets}
assert [f["name"] for f in by_name["OrdersNs"]["fields"]] == ["A"]
assert [f["name"] for f in by_name["InvoicesNs"]["fields"]] == ["B"]
class TestAuth:
def test_basic_auth_payload_attached(self):
conn = SAPODataConnector(
base_url="https://sap.example/odata/",
username="erp_user",
# Non-functional test placeholder; the low-entropy value keeps the
# secret scanner from treating it as a hardcoded credential.
password="test",
)
session = conn.get_session()
assert session.headers["Authorization"].startswith("Basic ")
# base64("erp_user:test")
assert session.headers["Authorization"].endswith("ZXJwX3VzZXI6dGVzdA==")
def test_oauth_token_exchange_goes_through_ssrf(self):
token_resp = MagicMock()
token_resp.status_code = 200
token_resp.headers = {}
token_resp.json.return_value = {"access_token": "tok-123"}
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
return_value=token_resp,
) as guard:
conn = SAPODataConnector(
base_url="https://sap.example/odata/",
token_url="https://auth.example/oauth/token",
client_id="cid",
client_secret="secret",
)
session = conn.get_session()
assert guard.call_count == 1
method_tok, url = guard.call_args[0]
assert method_tok == "POST"
assert url == "https://auth.example/oauth/token"
assert session.headers["Authorization"] == "Bearer tok-123"
body = guard.call_args.kwargs.get("data", {})
assert body["grant_type"] == "client_credentials"
def test_requires_auth(self):
with pytest.raises(ValidationError):
SAPODataConnector(base_url="https://sap.example/odata/")
def test_requires_base_url(self):
with pytest.raises(ValidationError):
SAPODataConnector(username="u", password="p")
class TestIngestPagination:
V4_PAGE = {
"value": [{"SalesOrderID": "SO-1"}, {"SalesOrderID": "SO-2"}],
"@odata.nextLink": "https://sap.example/odata/SalesOrderSet?$skiptoken=abc",
}
V4_LAST = {"value": [{"SalesOrderID": "SO-3"}]}
def test_v4_nextlink_paginates(self):
calls = []
def fake_guard(method, url, **kw):
page1 = _json_resp(self.V4_PAGE, url)
page2 = _json_resp(self.V4_LAST, url)
calls.append(url)
return page1 if method == "GET" and len(calls) == 1 else page2
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
side_effect=fake_guard,
):
ing = SAPIngestor(
base_url="https://sap.example/odata/",
username="u",
password="p",
)
result = ing.ingest_entity_set(entity_set="SalesOrderSet")
assert result.count == 3
assert [r["SalesOrderID"] for r in result.records] == [
"SO-1",
"SO-2",
"SO-3",
]
# Reached the second page's next link then stopped.
assert len(calls) == 2
def test_v4_nextlink_is_followed_past_first_page(self):
calls = []
def fake_guard(method, url, **kw):
calls.append(url)
if len(calls) == 1:
return _json_resp(self.V4_PAGE, url)
return _json_resp(self.V4_LAST, url)
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
side_effect=fake_guard,
):
ing = SAPIngestor(
base_url="https://sap.example/odata/",
username="u",
password="p",
)
ing.ingest_entity_set(entity_set="SalesOrderSet")
assert len(calls) == 2
def test_v2_atom_next_pagination(self):
v2_first = {
"d": {
"results": [{"SalesOrderID": "A"}],
"__next": {"__deferred": {"uri": "https://sap.example/odata/next2"}},
},
}
v2_last = {"d": {"results": [{"SalesOrderID": "B"}]}}
calls = []
def fake_guard(method, url, **kw):
calls.append(url)
return (
_json_resp(v2_first, url)
if len(calls) == 1
else _json_resp(v2_last, url)
)
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
side_effect=fake_guard,
):
ing = SAPIngestor(
base_url="https://sap.example/odata/",
username="u",
password="p",
)
result = ing.ingest_entity_set(entity_set="SalesOrderSet")
assert [r["SalesOrderID"] for r in result.records] == ["A", "B"]
assert len(calls) == 2
def test_v2_next_as_plain_string_paginates(self):
"""Canonical OData v2 JSON: ``__next`` is a plain string URL."""
v2_first = {
"d": {
"results": [{"SalesOrderID": "A"}],
"__next": "https://sap.example/odata/next2",
},
}
v2_last = {"d": {"results": [{"SalesOrderID": "B"}]}}
calls = []
def fake_guard(method, url, **kw):
calls.append(url)
if len(calls) == 1:
return _json_resp(v2_first, url)
return _json_resp(v2_last, url)
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
side_effect=fake_guard,
):
ing = SAPIngestor(
base_url="https://sap.example/odata/",
username="u",
password="p",
)
result = ing.ingest_entity_set(entity_set="SalesOrderSet")
assert [r["SalesOrderID"] for r in result.records] == ["A", "B"]
assert len(calls) == 2
def test_relative_service_resolves_against_base(self):
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
return_value=_json_resp({"value": []}, "https://x/"),
) as guard:
ing = SAPIngestor(
base_url="https://sap.example/odata/sap/",
username="u",
password="p",
)
ing.ingest_entity_set(
service="API_SALES_ORDER_SRV", entity_set="SalesOrderSet"
)
method, url = guard.call_args[0]
assert url == "https://sap.example/odata/sap/API_SALES_ORDER_SRV/SalesOrderSet"
def test_expand_passthrough_for_line_items(self):
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
return_value=_json_resp(
{"value": [{"SalesOrderID": "SO-9"}]}, "https://x/"
),
) as guard:
ing = SAPIngestor(
base_url="https://sap.example/odata/", username="u", password="p"
)
ing.ingest_entity_set(entity_set="SalesOrderSet", expand="to_Item")
params = guard.call_args_list[0].kwargs.get("params") or {}
assert params.get("$expand") == "to_Item"
def test_select_filter_top_skip_objects_passthrough(self):
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
return_value=_json_resp({"value": []}, "https://x/"),
) as guard:
ing = SAPIngestor(
base_url="https://sap.example/odata/", username="u", password="p"
)
ing.ingest_entity_set(
entity_set="SalesOrderSet",
select="SalesOrderID,GrossAmount",
filter="GrossAmount gt 100",
top=5,
skip=2,
)
params = guard.call_args_list[0].kwargs.get("params") or {}
assert params["$select"] == "SalesOrderID,GrossAmount"
assert params["$filter"] == "GrossAmount gt 100"
assert params["$top"] == "5"
assert params["$skip"] == "2"
class TestErrorPaths:
def test_http_error_on_entity_set_raises_processing_error(self):
with patch(
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
return_value=_fake_response(status_code=403),
):
ing = SAPIngestor(
base_url="https://sap.example/odata/",
username="u",
password="p",
)
with pytest.raises(ProcessingError, match="403"):
ing.ingest_entity_set(entity_set="SalesOrderSet")
def test_invalid_metadata_xml_raises_processing_error(self):
resp = _fake_response(text="