# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
"""Tests for SBOM validation adapters (CycloneDX + SPDX).
Covers:
- CycloneDXSchemaValidator: structural checks, full schema, from_schema_file
- SPDXSchemaValidator: structural checks, full schema, from_schema_file
- Protocol conformance (both implement Validator)
- Input normalisation (dict, JSON string, Path)
- Integration with generate_sbom output
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from releasekit.backends.validation import Validator, all_passed, run_validators
from releasekit.backends.validation.sbom import (
CycloneDXSchemaValidator,
SPDXSchemaValidator,
)
from releasekit.sbom import SBOMFormat, generate_sbom
from releasekit.versions import PackageVersion, ReleaseManifest
# ── Helpers ──
_SCHEMA_DIR = Path(__file__).parent / 'schemas'
def _manifest(*names_versions: tuple[str, str]) -> ReleaseManifest:
"""Build a minimal manifest from (name, version) pairs."""
return ReleaseManifest(
git_sha='abc123',
packages=[PackageVersion(name=n, old_version='0.0.0', new_version=v, bump='minor') for n, v in names_versions],
)
def _load_schema(name: str) -> dict:
path = _SCHEMA_DIR / name
if not path.exists():
return {}
return json.loads(path.read_text(encoding='utf-8'))
def _valid_cyclonedx() -> dict:
"""Minimal valid CycloneDX 1.5 document."""
return {
'bomFormat': 'CycloneDX',
'specVersion': '1.5',
'serialNumber': 'urn:uuid:00000000-0000-0000-0000-000000000000',
'version': 1,
'metadata': {
'timestamp': '2026-01-01T00:00:00Z',
'tools': {'components': [{'type': 'application', 'name': 'test', 'version': '0.1'}]},
},
'components': [
{'type': 'library', 'name': 'foo', 'version': '1.0.0'},
],
}
def _valid_spdx() -> dict:
"""Minimal valid SPDX 2.3 document."""
return {
'spdxVersion': 'SPDX-2.3',
'dataLicense': 'CC0-1.0',
'SPDXID': 'SPDXRef-DOCUMENT',
'name': 'test-sbom',
'documentNamespace': 'https://example.com/test',
'creationInfo': {
'created': '2026-01-01T00:00:00Z',
'creators': ['Tool: test-0.1'],
},
'packages': [
{
'SPDXID': 'SPDXRef-foo',
'name': 'foo',
'versionInfo': '1.0.0',
'downloadLocation': 'NOASSERTION',
'filesAnalyzed': False,
'licenseConcluded': 'Apache-2.0',
'licenseDeclared': 'Apache-2.0',
},
],
}
# ── Protocol conformance ──
class TestProtocolConformance:
"""Both SBOM validators implement the Validator protocol."""
def test_cyclonedx_is_validator(self) -> None:
assert isinstance(CycloneDXSchemaValidator(), Validator)
def test_spdx_is_validator(self) -> None:
assert isinstance(SPDXSchemaValidator(), Validator)
# ── CycloneDX structural checks ──
class TestCycloneDXStructural:
"""CycloneDXSchemaValidator structural checks (no full schema)."""
def test_name(self) -> None:
assert CycloneDXSchemaValidator().name == 'schema.cyclonedx'
def test_custom_name(self) -> None:
v = CycloneDXSchemaValidator(validator_id='custom.cdx')
assert v.name == 'custom.cdx'
def test_valid_passes(self) -> None:
r = CycloneDXSchemaValidator().validate(_valid_cyclonedx())
assert r.ok is True
def test_wrong_bom_format_fails(self) -> None:
doc = _valid_cyclonedx()
doc['bomFormat'] = 'NotCycloneDX'
r = CycloneDXSchemaValidator().validate(doc)
assert r.ok is False
assert 'bomFormat' in r.details.get('errors', [''])[0]
def test_missing_spec_version_fails(self) -> None:
doc = _valid_cyclonedx()
del doc['specVersion']
r = CycloneDXSchemaValidator().validate(doc)
assert r.ok is False
assert 'specVersion' in r.details.get('errors', [''])[0]
def test_component_missing_type_fails(self) -> None:
doc = _valid_cyclonedx()
doc['components'] = [{'name': 'foo'}]
r = CycloneDXSchemaValidator().validate(doc)
assert r.ok is False
assert 'type' in str(r.details.get('errors', []))
def test_component_missing_name_fails(self) -> None:
doc = _valid_cyclonedx()
doc['components'] = [{'type': 'library'}]
r = CycloneDXSchemaValidator().validate(doc)
assert r.ok is False
assert 'name' in str(r.details.get('errors', []))
def test_empty_components_passes(self) -> None:
doc = _valid_cyclonedx()
doc['components'] = []
r = CycloneDXSchemaValidator().validate(doc)
assert r.ok is True
def test_no_components_key_passes(self) -> None:
doc = _valid_cyclonedx()
del doc['components']
r = CycloneDXSchemaValidator().validate(doc)
assert r.ok is True
# ── SPDX structural checks ──
class TestSPDXStructural:
"""SPDXSchemaValidator structural checks (no full schema)."""
def test_name(self) -> None:
assert SPDXSchemaValidator().name == 'schema.spdx'
def test_valid_passes(self) -> None:
r = SPDXSchemaValidator().validate(_valid_spdx())
assert r.ok is True
def test_missing_spdx_version_fails(self) -> None:
doc = _valid_spdx()
del doc['spdxVersion']
r = SPDXSchemaValidator().validate(doc)
assert r.ok is False
assert 'spdxVersion' in str(r.details.get('errors', []))
def test_wrong_spdxid_fails(self) -> None:
doc = _valid_spdx()
doc['SPDXID'] = 'SPDXRef-WRONG'
r = SPDXSchemaValidator().validate(doc)
assert r.ok is False
assert 'SPDXID' in str(r.details.get('errors', []))
def test_missing_name_fails(self) -> None:
doc = _valid_spdx()
del doc['name']
r = SPDXSchemaValidator().validate(doc)
assert r.ok is False
assert 'name' in str(r.details.get('errors', []))
def test_missing_namespace_fails(self) -> None:
doc = _valid_spdx()
del doc['documentNamespace']
r = SPDXSchemaValidator().validate(doc)
assert r.ok is False
assert 'documentNamespace' in str(r.details.get('errors', []))
def test_missing_data_license_fails(self) -> None:
doc = _valid_spdx()
del doc['dataLicense']
r = SPDXSchemaValidator().validate(doc)
assert r.ok is False
assert 'dataLicense' in str(r.details.get('errors', []))
def test_missing_creation_info_fails(self) -> None:
doc = _valid_spdx()
del doc['creationInfo']
r = SPDXSchemaValidator().validate(doc)
assert r.ok is False
assert 'creationInfo' in str(r.details.get('errors', []))
def test_missing_created_in_creation_info_fails(self) -> None:
doc = _valid_spdx()
doc['creationInfo'] = {'creators': ['Tool: test']}
r = SPDXSchemaValidator().validate(doc)
assert r.ok is False
assert 'created' in str(r.details.get('errors', []))
def test_package_missing_spdxid_fails(self) -> None:
doc = _valid_spdx()
doc['packages'] = [{'name': 'foo', 'downloadLocation': 'NOASSERTION'}]
r = SPDXSchemaValidator().validate(doc)
assert r.ok is False
assert 'SPDXID' in str(r.details.get('errors', []))
def test_package_missing_download_location_fails(self) -> None:
doc = _valid_spdx()
doc['packages'] = [{'SPDXID': 'SPDXRef-foo', 'name': 'foo'}]
r = SPDXSchemaValidator().validate(doc)
assert r.ok is False
assert 'downloadLocation' in str(r.details.get('errors', []))
# ── Input normalisation ──
class TestInputNormalisation:
"""Both validators accept dict, JSON string, and Path."""
def test_cyclonedx_json_string(self) -> None:
r = CycloneDXSchemaValidator().validate(json.dumps(_valid_cyclonedx()))
assert r.ok is True
def test_spdx_json_string(self) -> None:
r = SPDXSchemaValidator().validate(json.dumps(_valid_spdx()))
assert r.ok is True
def test_cyclonedx_path(self, tmp_path: Path) -> None:
p = tmp_path / 'sbom.cdx.json'
p.write_text(json.dumps(_valid_cyclonedx()), encoding='utf-8')
r = CycloneDXSchemaValidator().validate(p)
assert r.ok is True
def test_spdx_path(self, tmp_path: Path) -> None:
p = tmp_path / 'sbom.spdx.json'
p.write_text(json.dumps(_valid_spdx()), encoding='utf-8')
r = SPDXSchemaValidator().validate(p)
assert r.ok is True
def test_missing_file_fails(self, tmp_path: Path) -> None:
r = CycloneDXSchemaValidator().validate(tmp_path / 'missing.json')
assert r.ok is False
assert 'File not found' in r.message
def test_invalid_json_string_fails(self) -> None:
r = CycloneDXSchemaValidator().validate('{bad json')
assert r.ok is False
assert 'Invalid JSON' in r.message
def test_unsupported_type_fails(self) -> None:
r = CycloneDXSchemaValidator().validate(42)
assert r.ok is False
assert 'Unsupported subject type' in r.message
# ── Full schema validation (with official schemas) ──
class TestCycloneDXFullSchema:
"""CycloneDX validation against the official 1.5 schema."""
@pytest.fixture()
def validator(self) -> CycloneDXSchemaValidator:
schema = _load_schema('bom-1.5.schema.json')
if not schema:
pytest.skip('CycloneDX 1.5 schema not available')
return CycloneDXSchemaValidator(schema=schema)
def test_valid_passes(self, validator: CycloneDXSchemaValidator) -> None:
r = validator.validate(_valid_cyclonedx())
assert r.ok is True
def test_generated_single_package(self, validator: CycloneDXSchemaValidator) -> None:
m = _manifest(('genkit', '0.5.0'))
doc = json.loads(generate_sbom(m, fmt=SBOMFormat.CYCLONEDX, supplier='Google LLC'))
r = validator.validate(doc)
assert r.ok is True, f'Schema errors: {r.details}'
def test_generated_multi_package(self, validator: CycloneDXSchemaValidator) -> None:
m = _manifest(('genkit', '0.5.0'), ('plugin-a', '0.3.0'), ('plugin-b', '0.2.0'))
doc = json.loads(generate_sbom(m, fmt=SBOMFormat.CYCLONEDX, supplier='Google LLC'))
r = validator.validate(doc)
assert r.ok is True, f'Schema errors: {r.details}'
def test_generated_js_ecosystem(self, validator: CycloneDXSchemaValidator) -> None:
m = _manifest(('react', '18.0.0'), ('@genkit/core', '0.5.0'))
doc = json.loads(generate_sbom(m, fmt=SBOMFormat.CYCLONEDX, ecosystem='js'))
r = validator.validate(doc)
assert r.ok is True, f'Schema errors: {r.details}'
def test_generated_no_supplier(self, validator: CycloneDXSchemaValidator) -> None:
m = _manifest(('foo', '1.0.0'))
doc = json.loads(generate_sbom(m, fmt=SBOMFormat.CYCLONEDX))
r = validator.validate(doc)
assert r.ok is True, f'Schema errors: {r.details}'
def test_invalid_fails(self, validator: CycloneDXSchemaValidator) -> None:
r = validator.validate({'bomFormat': 'CycloneDX', 'specVersion': '1.5'})
# May pass structural but fail full schema (missing version field).
# Either way, the validator should not crash.
assert isinstance(r.ok, bool)
def test_from_schema_file(self) -> None:
path = _SCHEMA_DIR / 'bom-1.5.schema.json'
if not path.exists():
pytest.skip('CycloneDX 1.5 schema not available')
v = CycloneDXSchemaValidator.from_schema_file(path)
r = v.validate(_valid_cyclonedx())
assert r.ok is True
class TestSPDXFullSchema:
"""SPDX validation against the official 2.3 schema."""
@pytest.fixture()
def validator(self) -> SPDXSchemaValidator:
schema = _load_schema('spdx-2.3.schema.json')
if not schema:
pytest.skip('SPDX 2.3 schema not available')
return SPDXSchemaValidator(schema=schema)
def test_valid_passes(self, validator: SPDXSchemaValidator) -> None:
r = validator.validate(_valid_spdx())
assert r.ok is True
def test_generated_single_package(self, validator: SPDXSchemaValidator) -> None:
m = _manifest(('genkit', '0.5.0'))
doc = json.loads(generate_sbom(m, fmt=SBOMFormat.SPDX, supplier='Google LLC'))
r = validator.validate(doc)
assert r.ok is True, f'Schema errors: {r.details}'
def test_generated_multi_package(self, validator: SPDXSchemaValidator) -> None:
m = _manifest(('genkit', '0.5.0'), ('plugin-a', '0.3.0'))
doc = json.loads(generate_sbom(m, fmt=SBOMFormat.SPDX, supplier='Google LLC'))
r = validator.validate(doc)
assert r.ok is True, f'Schema errors: {r.details}'
def test_generated_no_license(self, validator: SPDXSchemaValidator) -> None:
m = _manifest(('bar', '2.0.0'))
doc = json.loads(generate_sbom(m, fmt=SBOMFormat.SPDX, license_id=''))
r = validator.validate(doc)
assert r.ok is True, f'Schema errors: {r.details}'
def test_from_schema_file(self) -> None:
path = _SCHEMA_DIR / 'spdx-2.3.schema.json'
if not path.exists():
pytest.skip('SPDX 2.3 schema not available')
v = SPDXSchemaValidator.from_schema_file(path)
r = v.validate(_valid_spdx())
assert r.ok is True
# ── run_validators integration ──
class TestRunValidatorsIntegration:
"""Test running both SBOM validators together via run_validators."""
def test_both_pass_on_cyclonedx(self) -> None:
v = CycloneDXSchemaValidator()
results = run_validators([v], _valid_cyclonedx())
assert len(results) == 1
assert results[0].ok is True
def test_both_formats_validated(self) -> None:
cdx_v = CycloneDXSchemaValidator()
spdx_v = SPDXSchemaValidator()
m = _manifest(('genkit', '0.5.0'))
cdx_doc = json.loads(generate_sbom(m, fmt=SBOMFormat.CYCLONEDX))
spdx_doc = json.loads(generate_sbom(m, fmt=SBOMFormat.SPDX))
cdx_results = run_validators([cdx_v], cdx_doc)
spdx_results = run_validators([spdx_v], spdx_doc)
assert all_passed(cdx_results)
assert all_passed(spdx_results)