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 "")