diff --git a/assets/validate/checkIVOAOAI.xsl b/assets/validate/checkIVOAOAI.xsl index 3d5c3b8..fb76258 100644 --- a/assets/validate/checkIVOAOAI.xsl +++ b/assets/validate/checkIVOAOAI.xsl @@ -8,7 +8,7 @@ exclude-result-prefixes="vr vg ri oai xsi" version="1.0"> - + - 2007-02-24T14:49:50 + diff --git a/assets/validate/testsVOResource.xsl b/assets/validate/testsVOResource.xsl new file mode 100644 index 0000000..419ec34 --- /dev/null +++ b/assets/validate/testsVOResource.xsl @@ -0,0 +1,605 @@ + + + + + + + + + + + + + + + + + + + + VR3.1b1 + + warn + + VOResource record should have a status attribute + + + + + + + + VR3.1b2 + + warn + + VOResource record should have an updated attribute + + + + + + + + VR3.1b3 + + warn + + VOResource record should have a created attribute + + + + + + + + + + + + + + + + + + + SIA7.0a + + rec + + Recommend setting VOResource xsi:type='vs:CatalogService' on service with SimpleImageAccess capability + + + + + + + + + + SIA7.0b + + + SimpleImageAccess capability must include interface with xsi:type='vs:ParamHTTP' and role='std' + + + + + + + + + + + + + + + + + + + CS4.0a + + rec + + Recommend setting VOResource xsi:type='vs:CatalogService' on service with ConeSearch capability + + + + + + + + + + CS4.0b + + + ConeSearch capability must include interface with xsi:type='vs:ParamHTTP' and role='std' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + RI4.0b + + rec + + Recommend setting VOResource xsi:type='vg:Registry' on service with a Search or Harvest capability + + + + + + + + + + + RI4.0c + + + Harvest capability must include interface with xsi:type='vg:OAIHTTP' and role='std' + + + + + + + + + + + RI4.0c + + + Search capability must include interface with xsi:type='vr:Webservice' and role='std' + + + + + + + + + + + + + + + + + + + SN4.0a + + rec + + Recommend setting VOResource xsi:type='vs:CatalogService' on service with SkyNode capability + + + + + + + + + + SN4.0b + + + SimpleImageAccess capability must include interface with xsi:type='vr:WebService' and role='std' + + + + + + + + + + + + + + + + + VR3.2.2a + + warn + + Service resource should have at least one capability element + + + + + + + + + + + + + + + + + VR3.2.2a + + warn + + capability element should have at least one interface element + + + + + + + + + + + + created attribute + + + + + updated attribute + + + + + + date element + + + + + + + + date element + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + : + + + + + + + + + + + VRdate + + + The date for the + + must be in the Past + + + + + + + + + + + + + + + + + + + + + + + + + + + + + T + + + + + + + + + + + + + + + + + T + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + RI3.1.4b + + + The Registry must not serve records with + authorities it does not claim in its + Identify response ("managedAuthority"). + + + + + + + + + + + + + + + + + + + + + + + + + + + 4.3.2b + + + A harvesting registry must support version + 1.0 of the OAIHTTP interface. + + + + + diff --git a/src/benson/oai/phase2.py b/src/benson/oai/phase2.py index b784def..7a40e6b 100644 --- a/src/benson/oai/phase2.py +++ b/src/benson/oai/phase2.py @@ -151,9 +151,17 @@ async def build_ivoa_harvest_validation( continue if check_xsl.is_file(): - xsl_params: dict[str, str] = {"expectError": "false"} + xsl_params: dict[str, str] = { + "expectError": "false", + "queryType": role, + "queryName": role, + "baseurl": endpoint.rstrip().rstrip("?"), + "showStatus": show_status, + } if role == "ListRecords" and identify_state: xsl_params.update(identify_state) + # Always set last so Identify-derived params cannot clobber it. + xsl_params["rightnow"] = xslt_eval.rightnow() try: xout = xslt_eval.transform( check_xsl, diff --git a/src/benson/oai/phase3.py b/src/benson/oai/phase3.py index 438e2a7..eb118db 100644 --- a/src/benson/oai/phase3.py +++ b/src/benson/oai/phase3.py @@ -119,6 +119,21 @@ async def harvest_voresource_documents( return collected, stats +def _xslt_fail_messages(xout: etree._ElementTree) -> list[str]: # noqa: SLF001 + """Collect fail-status ```` messages from a checkVOResource result.""" + errs: list[str] = [] + root = xout.getroot() + if root is None: + return errs + for test in root.findall("test"): + if (test.get("status") or "").strip().lower() != "fail": + continue + item = (test.get("item") or "VRvalid").strip() + desc = (test.text or "").strip() + errs.append(f"{item}: {desc}" if desc else item) + return errs + + def validate_one_voresource( blob: bytes, builtin_schemas: bool, @@ -145,12 +160,21 @@ def validate_one_voresource( errs = [] xsl_path = settings.assets_root / "checkVOResource.xsl" - if xsl_path.is_file(): - try: - _ = xslt_eval.transform(xsl_path, el) - except Exception: - pass + if not xsl_path.is_file(): + return errs + + try: + xout = xslt_eval.transform( + xsl_path, + el, + params={"rightnow": xslt_eval.rightnow()}, + ) + except etree.LxmlError: + # Stylesheet missing imports / apply failure: fall back to XSD-only + # (see docs/schemas-and-validation-assets.md). + return errs + errs.extend(_xslt_fail_messages(xout)) return errs diff --git a/src/benson/xml/xslt_eval.py b/src/benson/xml/xslt_eval.py index 72acf87..4b17214 100644 --- a/src/benson/xml/xslt_eval.py +++ b/src/benson/xml/xslt_eval.py @@ -2,11 +2,17 @@ from __future__ import annotations +from datetime import UTC, datetime from pathlib import Path from lxml import etree +def rightnow() -> str: + """UTC timestamp for stylesheet ``rightnow`` params (legacy Java format).""" + return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + + def transform( xsl_path: Path, source_doc: etree._ElementTree | etree._Element, diff --git a/tests/test_validate_one_voresource.py b/tests/test_validate_one_voresource.py new file mode 100644 index 0000000..49b0b1f --- /dev/null +++ b/tests/test_validate_one_voresource.py @@ -0,0 +1,209 @@ +"""Single-record VOResource validation (``validate_one_voresource``).""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest +from lxml import etree + +_repo = Path(__file__).resolve().parents[1] +os.environ.setdefault("SCHEMA_ROOT", str(_repo / "assets" / "schemas")) +os.environ.setdefault("ASSETS_ROOT", str(_repo / "assets" / "validate")) + +from benson.config import Settings # noqa: E402 +from benson.oai.phase3 import validate_one_voresource, validate_voresource_documents # noqa: E402 + +_RI = "http://www.ivoa.net/xml/RegistryInterface/v1.0" +_VR = "http://www.ivoa.net/xml/VOResource/v1.0" + +# Minimal well-formed RI resource (not necessarily XSD-valid). +_MINIMAL_VOR = f"""\ + + + Example + ivo://example/test + + Example + + + testing + minimal fixture + http://example.com/ + + +""".encode() + + +@pytest.fixture +def settings() -> Settings: + return Settings.from_env() + + +def _fake_xslt_tree(*tests: etree._Element) -> etree._ElementTree: + root = etree.Element("VOResourceValidation") + for t in tests: + root.append(t) + return etree.ElementTree(root) + + +def test_validate_one_raises_on_malformed_xml(settings: Settings) -> None: + with pytest.raises(etree.XMLSyntaxError): + validate_one_voresource(b"", builtin_schemas=False, settings=settings) + + +def test_validate_one_surfaces_xsd_errors(settings: Settings) -> None: + errs = validate_one_voresource( + _MINIMAL_VOR, + builtin_schemas=True, + settings=settings, + ) + assert errs, "expected schema violations for incomplete resource" + + +def test_validate_one_skips_xsd_when_builtin_disabled(settings: Settings) -> None: + with patch("benson.oai.phase3.xslt_eval.transform", side_effect=etree.XSLTParseError("boom")): + errs = validate_one_voresource( + _MINIMAL_VOR, + builtin_schemas=False, + settings=settings, + ) + assert errs == [] + + +def test_validate_one_xslt_failure_falls_back(settings: Settings) -> None: + """XSLT load/apply errors must not crash; fall back to XSD-only results.""" + with patch( + "benson.oai.phase3.xslt_eval.transform", + side_effect=etree.XSLTParseError("Cannot resolve URI testsVOResource.xsl"), + ): + errs = validate_one_voresource( + _MINIMAL_VOR, + builtin_schemas=False, + settings=settings, + ) + assert errs == [] + + +def test_validate_one_collects_xslt_fail_tests(settings: Settings) -> None: + fail = etree.Element("test", item="use-voc-content_type", status="fail") + fail.text = "#bogus not in vocabulary" + warn = etree.Element("test", item="use-voc-uat", status="warn") + warn.text = "#old deprecated" + ok = etree.Element("test", item="VRvalid", status="pass") + ok.text = "OK" + xout = _fake_xslt_tree(fail, warn, ok) + + with patch("benson.oai.phase3.xslt_eval.transform", return_value=xout): + errs = validate_one_voresource( + _MINIMAL_VOR, + builtin_schemas=False, + settings=settings, + ) + + assert errs == ["use-voc-content_type: #bogus not in vocabulary"] + + +def test_validate_one_xslt_pass_yields_no_errors(settings: Settings) -> None: + ok = etree.Element("test", item="VRvalid", status="pass") + ok.text = "OK" + with patch("benson.oai.phase3.xslt_eval.transform", return_value=_fake_xslt_tree(ok)): + errs = validate_one_voresource( + _MINIMAL_VOR, + builtin_schemas=False, + settings=settings, + ) + assert errs == [] + + +def test_validate_one_passes_current_rightnow(settings: Settings) -> None: + """Past created/updated dates must not fail VRdate when rightnow is current.""" + from benson.xml import xslt_eval + + # Ensure the stylesheet actually loads (no silent XSLT fallback). + xslt_eval.transform( + settings.assets_root / "checkVOResource.xsl", + etree.fromstring(_MINIMAL_VOR), + params={"rightnow": xslt_eval.rightnow()}, + ) + + errs = validate_one_voresource( + _MINIMAL_VOR, + builtin_schemas=False, + settings=settings, + ) + assert not any(e.startswith("VRdate:") for e in errs) + + +def test_validate_one_stale_rightnow_fails_vrdate(settings: Settings) -> None: + """A stale evaluation clock must still surface VRdate failures.""" + from benson.xml import xslt_eval + + out = xslt_eval.transform( + settings.assets_root / "checkVOResource.xsl", + etree.fromstring(_MINIMAL_VOR), + params={"rightnow": "2007-02-24T14:49:50"}, + ) + fails = [ + t + for t in out.getroot().findall("test") + if t.get("item") == "VRdate" and t.get("status") == "fail" + ] + assert fails + assert any("created attribute" in (t.text or "") for t in fails) + + +def test_validate_one_passes_rightnow_param_to_xslt(settings: Settings) -> None: + captured: dict[str, str] = {} + + def _capture(xsl_path, el, *, params=None): # noqa: ANN001 + captured.update(params or {}) + return _fake_xslt_tree() + + with patch("benson.oai.phase3.xslt_eval.transform", side_effect=_capture): + with patch( + "benson.oai.phase3.xslt_eval.rightnow", + return_value="2026-07-29T12:00:00", + ): + validate_one_voresource( + _MINIMAL_VOR, + builtin_schemas=False, + settings=settings, + ) + + assert captured.get("rightnow") == "2026-07-29T12:00:00" + + +def test_validate_one_voc_exercise_xsd_reports_errors(settings: Settings) -> None: + blob = (_repo / "tests" / "fixtures" / "voc-exercise.vor").read_bytes() + with patch( + "benson.oai.phase3.xslt_eval.transform", + side_effect=etree.XSLTParseError("unavailable"), + ): + errs = validate_one_voresource(blob, builtin_schemas=True, settings=settings) + assert any("stats" in e for e in errs) + + +def test_validate_voresource_documents_uses_validate_one(settings: Settings) -> None: + fail = etree.Element("test", item="use-voc-messenger", status="fail") + fail.text = "#nope not in vocabulary" + with patch( + "benson.oai.phase3.xslt_eval.transform", + return_value=_fake_xslt_tree(fail), + ): + root, stats = validate_voresource_documents( + {"ivo://example/test": _MINIMAL_VOR}, + "fail warn rec", + builtin_schemas=False, + settings=settings, + ) + assert stats.nfail == 1 + assert stats.npass == 0 + test = root.find(".//test") + assert test is not None + assert test.get("status") == "fail" + assert "use-voc-messenger" in (test.text or "")