diff --git a/assets/validate/README.md b/assets/validate/README.md index 7a2ebd7..b4de84b 100644 --- a/assets/validate/README.md +++ b/assets/validate/README.md @@ -1,11 +1,14 @@ # Validation stylesheets (XSLT) Default **`ASSETS_ROOT`**: XSLT 1.0 stylesheets for IVOA registry rules beyond XSD. +These files are required for validation; a missing or unloadable stylesheet fails hard +(see [`docs/schemas-and-validation-assets.md`](../../docs/schemas-and-validation-assets.md)). | File | Role | |------|------| | `checkIVOAOAI.xsl` | IVOA harvest profile tests on OAI GET responses (phase 2) | | `checkVOResource.xsl` | VOResource constraint tests on harvested records (phase 3) | +| `testsVOResource.xsl` | Shared VOResource rule templates (imported by the check stylesheets) | | `validationCommon.xsl` | Shared helpers (imported by the check stylesheets) | | `validateVocabularies.xsl` | Generated vocabulary-term checks (imported by `checkVOResource.xsl`) | | `vocabularyControlled.csv` | Config for which VOResource paths are vocabulary-controlled | diff --git a/docs/regvalidate-functional-contract.md b/docs/regvalidate-functional-contract.md index b451f40..15b7158 100644 --- a/docs/regvalidate-functional-contract.md +++ b/docs/regvalidate-functional-contract.md @@ -217,7 +217,7 @@ Logic and presentation artifacts live in **`ivoaharvest/src/main/resources/net/i | `checkIVOAOAI.xsl` | IVOA OAI profile tests on harvested HTTP GET responses. | | `checkVOResource.xsl` | VOR constraints beyond XSD. | | `validationCommon.xsl` | Shared helpers. | -| `testsVOResource-v1_0.xsl` | Additional VOR tests (if used). | +| `testsVOResource.xsl` | Additional VOR tests (if used). | | `Results-Harvest-html.xsl` | Harvest results HTML view. | | `ResultsFrag-Harvest-html.xsl`, `SummaryFrag-Harvest-html.xsl` | UI fragments for browser transform. | | `Results-VOResource-html.xsl` | Standalone VOR HTML view. | diff --git a/docs/schemas-and-validation-assets.md b/docs/schemas-and-validation-assets.md index 95c6462..de0e18e 100644 --- a/docs/schemas-and-validation-assets.md +++ b/docs/schemas-and-validation-assets.md @@ -136,13 +136,12 @@ These stylesheets implement IVOA registry **business rules** that XSD alone does |------|---------|------| | `checkIVOAOAI.xsl` | Phase 2 | Profile tests on OAI GET responses (`Identify`, `ListMetadataFormats`, `ListSets`, `ListRecords`). Emits `` elements. | | `checkVOResource.xsl` | Phase 3 | Additional constraints on harvested VOResource records. Emits ``. | +| `testsVOResource.xsl` | (imported) | VOResource business-rule templates (`coretests` / `restests` / `captests`) used by both check stylesheets | | `validationCommon.xsl` | (imported) | Shared helpers for the check stylesheets | | `validateVocabularies.xsl` | Phase 3 (imported) | Generated checks for vocabulary-controlled VOResource / VODataService fields | | `vocabularyControlled.csv` | (generator input) | Paths and IVOA vocabulary URLs used to build `validateVocabularies.xsl` | -If XSLT processing fails or the stylesheet is missing, phase 2/3 fall back to simpler pass/fail heuristics (HTTP status and absence of OAI error codes). - -**Note:** `checkIVOAOAI.xsl` references `testsVOResource-v1_0.xsl` from the legacy Java tree; that file is **not** shipped under `assets/validate/`. Import failures are caught and the code falls back to non-XSLT checks. +XSLT is **required** for phase 2/3 validation. If a stylesheet is missing or cannot be loaded (for example a broken `xsl:import`), Benson raises `XsltAssetsError` with guidance to fix `ASSETS_ROOT` / regenerate vocabulary XSLT. Silent fall-back to XSD-only or HTTP heuristics is intentionally not used: a local install missing assets would otherwise “pass” services that fail on a complete RofR deployment. ### Regenerating `validateVocabularies.xsl` diff --git a/src/benson/oai/phase2.py b/src/benson/oai/phase2.py index 7a40e6b..2e0783a 100644 --- a/src/benson/oai/phase2.py +++ b/src/benson/oai/phase2.py @@ -107,13 +107,13 @@ async def build_ivoa_harvest_validation( ) -> tuple[etree._Element, dict[str, str]]: root = R.harvest_validation_root(endpoint.rstrip(), show_status) - check_xsl = settings.assets_root / "checkIVOAOAI.xsl" + check_xsl = xslt_eval.require_stylesheet(settings.assets_root / "checkIVOAOAI.xsl") identify_state: dict[str, str] = {} registration_defaults: dict[str, str] = {} for role, qp in IVOA_CHECKS: qp_body = qp.lstrip().lstrip("?") - status, raw, codes, parse_err = await fetch_oai(client, endpoint, qp_body, timeout=timeout) + status, raw, _codes, parse_err = await fetch_oai(client, endpoint, qp_body, timeout=timeout) tq = etree.SubElement(root, "testQuery", name=role, options=qp, role=role) @@ -140,7 +140,6 @@ async def build_ivoa_harvest_validation( identify_state = extract_identify_state(parsed) registration_defaults = identify_registration_defaults(parsed) - used_xslt = False if violations: tq.append( R.ri_test( @@ -150,33 +149,23 @@ async def build_ivoa_harvest_validation( ) continue - if check_xsl.is_file(): - 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, - parsed, - params=xsl_params, - ) - for child in xout.getroot(): - used_xslt = True - tq.append(child) - except Exception: - used_xslt = False - - if not used_xslt: - ri_ok = not codes - msg = "OK" if ri_ok else _failure_message(role) - tq.append(R.ri_test(ri_ok, msg)) + 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() + xout = xslt_eval.transform( + check_xsl, + parsed, + params=xsl_params, + ) + for child in xout.getroot(): + tq.append(child) return root, registration_defaults diff --git a/src/benson/oai/phase3.py b/src/benson/oai/phase3.py index eb118db..25a06df 100644 --- a/src/benson/oai/phase3.py +++ b/src/benson/oai/phase3.py @@ -1,4 +1,4 @@ -"""Phase 3: ListRecords harvest loop (ivo_vor / ivo_managed) with XSD + optional XSLT.""" +"""Phase 3: ListRecords harvest loop (ivo_vor / ivo_managed) with XSD + XSLT.""" from __future__ import annotations @@ -160,20 +160,11 @@ def validate_one_voresource( errs = [] xsl_path = settings.assets_root / "checkVOResource.xsl" - 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 - + xout = xslt_eval.transform( + xsl_path, + el, + params={"rightnow": xslt_eval.rightnow()}, + ) 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 4b17214..7d262b3 100644 --- a/src/benson/xml/xslt_eval.py +++ b/src/benson/xml/xslt_eval.py @@ -8,22 +8,48 @@ from lxml import etree +class XsltAssetsError(RuntimeError): + """Raised when a required validation stylesheet is missing or cannot be loaded.""" + + def rightnow() -> str: """UTC timestamp for stylesheet ``rightnow`` params (legacy Java format).""" return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") +def require_stylesheet(xsl_path: Path) -> Path: + """Return ``xsl_path`` if it exists; otherwise raise with fix guidance.""" + if not xsl_path.is_file(): + raise XsltAssetsError( + f"Required XSLT stylesheet missing: {xsl_path}. " + "Set ASSETS_ROOT to the assets/validate directory shipped with Benson " + "(including checkIVOAOAI.xsl / checkVOResource.xsl and their imports: " + "testsVOResource.xsl, validationCommon.xsl, validateVocabularies.xsl). " + "If validateVocabularies.xsl is absent, run: benson generate-vocabulary-xsl" + ) + return xsl_path + + def transform( xsl_path: Path, source_doc: etree._ElementTree | etree._Element, *, params: dict[str, str] | None = None, ) -> etree._ElementTree: + require_stylesheet(xsl_path) doc = ( source_doc if isinstance(source_doc, etree._ElementTree) else etree.ElementTree(source_doc) ) - tpl = etree.XSLT(etree.parse(str(xsl_path))) + try: + tpl = etree.XSLT(etree.parse(str(xsl_path))) + except etree.LxmlError as exc: + raise XsltAssetsError( + f"Failed to load XSLT stylesheet {xsl_path}: {exc}. " + "Ensure ASSETS_ROOT contains the stylesheet and its imports " + "(testsVOResource.xsl, validationCommon.xsl, validateVocabularies.xsl). " + "If vocabulary checks are involved, run: benson generate-vocabulary-xsl" + ) from exc kw = {k.replace(":", "_"): etree.XSLT.strparam(v) for k, v in (params or {}).items()} return tpl(doc, **kw) diff --git a/tests/test_validate_one_voresource.py b/tests/test_validate_one_voresource.py index 49b0b1f..4c38e5b 100644 --- a/tests/test_validate_one_voresource.py +++ b/tests/test_validate_one_voresource.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from dataclasses import replace from pathlib import Path from unittest.mock import patch @@ -15,6 +16,7 @@ from benson.config import Settings # noqa: E402 from benson.oai.phase3 import validate_one_voresource, validate_voresource_documents # noqa: E402 +from benson.xml.xslt_eval import XsltAssetsError # noqa: E402 _RI = "http://www.ivoa.net/xml/RegistryInterface/v1.0" _VR = "http://www.ivoa.net/xml/VOResource/v1.0" @@ -66,20 +68,9 @@ def test_validate_one_surfaces_xsd_errors(settings: Settings) -> None: 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"), + return_value=_fake_xslt_tree(), ): errs = validate_one_voresource( _MINIMAL_VOR, @@ -89,6 +80,33 @@ def test_validate_one_xslt_failure_falls_back(settings: Settings) -> None: assert errs == [] +def test_validate_one_missing_stylesheet_fails_hard( + settings: Settings, + tmp_path: Path, +) -> None: + """Missing XSLT assets must fail hard, not silently skip rule checks.""" + broken = replace(settings, assets_root=tmp_path) + with pytest.raises(XsltAssetsError, match="Required XSLT stylesheet missing"): + validate_one_voresource(_MINIMAL_VOR, builtin_schemas=False, settings=broken) + + +def test_validate_one_stylesheet_load_failure_fails_hard( + settings: Settings, + tmp_path: Path, +) -> None: + """Unloadable stylesheets (e.g. broken imports) must fail hard with fix guidance.""" + (tmp_path / "checkVOResource.xsl").write_text( + '\n' + '\n' + ' \n' + "\n", + encoding="utf-8", + ) + broken = replace(settings, assets_root=tmp_path) + with pytest.raises(XsltAssetsError, match="Failed to load XSLT stylesheet"): + validate_one_voresource(_MINIMAL_VOR, builtin_schemas=False, settings=broken) + + 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" @@ -182,7 +200,7 @@ def test_validate_one_voc_exercise_xsd_reports_errors(settings: Settings) -> Non blob = (_repo / "tests" / "fixtures" / "voc-exercise.vor").read_bytes() with patch( "benson.oai.phase3.xslt_eval.transform", - side_effect=etree.XSLTParseError("unavailable"), + return_value=_fake_xslt_tree(), ): errs = validate_one_voresource(blob, builtin_schemas=True, settings=settings) assert any("stats" in e for e in errs)