diff --git a/custom_components/hacs/validate/license.py b/custom_components/hacs/validate/license.py new file mode 100644 index 00000000000..2e6ec6a1261 --- /dev/null +++ b/custom_components/hacs/validate/license.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..utils.json import json_loads +from .base import ActionValidationBase, ValidationException + +if TYPE_CHECKING: + from ..repositories.base import HacsRepository + +# renovate: datasource=github-tags depName=spdx/license-list-data +SPDX_LICENSE_LIST_COMMIT = "c4a7237ec8f4654e867546f9f409749300f1bf4c" # v3.28.0 + +SPDX_LICENSE_LIST_URL = ( + "https://raw.githubusercontent.com/spdx/license-list-data/" + f"{SPDX_LICENSE_LIST_COMMIT}/json/licenses.json" +) + + +async def async_setup_validator(repository: HacsRepository) -> Validator: + """Set up this validator.""" + return Validator(repository=repository) + + +class Validator(ActionValidationBase): + """Validate the repository.""" + + more_info = "https://hacs.xyz/docs/publish/include#check-license" + allow_fork = False + + async def async_validate(self) -> None: + """Validate the repository.""" + if (license_info := self.repository.repository_object.attributes.get("license")) is None: + raise ValidationException("The repository has no license") + + spdx_id = license_info.get("spdx_id") + if not spdx_id: + raise ValidationException("The repository license is missing an SPDX ID") + if spdx_id == "NOASSERTION": + raise ValidationException( + "The repository license could not be identified (SPDX: NOASSERTION)" + ) + + result = await self.hacs.async_download_file(SPDX_LICENSE_LIST_URL, handle_rate_limit=True) + if result is None: + raise ValidationException("Could not fetch the SPDX license list") + + try: + licenses = json_loads(result).get("licenses", []) + except Exception as err: + raise ValidationException("Could not parse the SPDX license list") from err + + osi_approved = { + entry.get("licenseId") + for entry in licenses + if entry.get("isOsiApproved") and entry.get("licenseId") + } + + if spdx_id not in osi_approved: + raise ValidationException( + f"The repository does not have an OSI-approved license (detected: '{spdx_id}')" + ) + + self.repository.logger.debug( + "The repository has an OSI-approved license: %s", license_info.get("name") + ) diff --git a/tests/action/test_hacs_action_integration.py b/tests/action/test_hacs_action_integration.py index 3ab9d6328a7..355c7496717 100644 --- a/tests/action/test_hacs_action_integration.py +++ b/tests/action/test_hacs_action_integration.py @@ -110,7 +110,7 @@ async def test_hacs_action_integration( await preflight() assert ( - "All (8) checks passed" if test_case["succeed"] else "1/8 checks failed") in caplog.text + "All (9) checks passed" if test_case["succeed"] else "1/9 checks failed") in caplog.text splitlines = [f"<{line.rsplit(' <')[1]}" for line in caplog.text.split( "\n") if " <" in line] diff --git a/tests/fixtures/proxy/raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json b/tests/fixtures/proxy/raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json new file mode 100644 index 00000000000..ce0f40bf24c --- /dev/null +++ b/tests/fixtures/proxy/raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json @@ -0,0 +1,24 @@ +{ + "licenseListVersion": "3.28.0", + "licenses": [ + { + "licenseId": "CC0-1.0", + "name": "Creative Commons Zero v1.0 Universal", + "isDeprecatedLicenseId": false, + "isOsiApproved": false + }, + { + "licenseId": "GPL-3.0", + "name": "GNU General Public License v3.0 only", + "isDeprecatedLicenseId": true, + "isOsiApproved": true + }, + { + "licenseId": "MIT", + "name": "MIT License", + "isDeprecatedLicenseId": false, + "isOsiApproved": true + } + ], + "releaseDate": "2025-05-30" +} diff --git a/tests/snapshots/action/test_hacs_action_integration/bad_documentation.log b/tests/snapshots/action/test_hacs_action_integration/bad_documentation.log index cf1bdadbd25..e20a2b2732b 100644 --- a/tests/snapshots/action/test_hacs_action_integration/bad_documentation.log +++ b/tests/snapshots/action/test_hacs_action_integration/bad_documentation.log @@ -10,8 +10,9 @@ completed failed: invalid url for dictionary value @ data['documentation']. Got None (More info: https://hacs.xyz/docs/publish/include#check-manifest ) completed + completed completed - 1/8 checks failed + 1/9 checks failed Validation completed ::group::data { diff --git a/tests/snapshots/action/test_hacs_action_integration/bad_issue_tracker.log b/tests/snapshots/action/test_hacs_action_integration/bad_issue_tracker.log index 982759ef7fb..e2a3458a234 100644 --- a/tests/snapshots/action/test_hacs_action_integration/bad_issue_tracker.log +++ b/tests/snapshots/action/test_hacs_action_integration/bad_issue_tracker.log @@ -10,8 +10,9 @@ completed failed: invalid url for dictionary value @ data['issue_tracker']. Got None (More info: https://hacs.xyz/docs/publish/include#check-manifest ) completed + completed completed - 1/8 checks failed + 1/9 checks failed Validation completed ::group::data { diff --git a/tests/snapshots/action/test_hacs_action_integration/no_releases.log b/tests/snapshots/action/test_hacs_action_integration/no_releases.log index 3da59bf0192..c65f466cb27 100644 --- a/tests/snapshots/action/test_hacs_action_integration/no_releases.log +++ b/tests/snapshots/action/test_hacs_action_integration/no_releases.log @@ -10,8 +10,9 @@ completed completed completed + completed completed - All (8) checks passed + All (9) checks passed Validation completed ::group::data { diff --git a/tests/snapshots/action/test_hacs_action_integration/releases_without_assets.log b/tests/snapshots/action/test_hacs_action_integration/releases_without_assets.log index 0d9730ccee5..2ed0b2175f7 100644 --- a/tests/snapshots/action/test_hacs_action_integration/releases_without_assets.log +++ b/tests/snapshots/action/test_hacs_action_integration/releases_without_assets.log @@ -10,8 +10,9 @@ completed completed completed + completed completed - All (8) checks passed + All (9) checks passed Validation completed ::group::data { diff --git a/tests/snapshots/action/test_hacs_action_integration/valid_manifest.log b/tests/snapshots/action/test_hacs_action_integration/valid_manifest.log index f074c070bb7..3647ca91f23 100644 --- a/tests/snapshots/action/test_hacs_action_integration/valid_manifest.log +++ b/tests/snapshots/action/test_hacs_action_integration/valid_manifest.log @@ -10,8 +10,9 @@ completed completed completed + completed completed - All (8) checks passed + All (9) checks passed Validation completed ::group::data { diff --git a/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-bad-documentation.json b/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-bad-documentation.json index caf277160c2..07d7a370840 100644 --- a/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-bad-documentation.json +++ b/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-bad-documentation.json @@ -7,6 +7,7 @@ "https://api.github.com/repos/hacs-test-org/integration-basic/releases": 1, "https://brands.home-assistant.io/domains.json": 1, "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/custom_components/example/manifest.json": 1, - "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/hacs.json": 1 + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/hacs.json": 1, + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json": 1 } } \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-bad-issue-tracker.json b/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-bad-issue-tracker.json index 24745ef7f08..670fbe87922 100644 --- a/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-bad-issue-tracker.json +++ b/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-bad-issue-tracker.json @@ -7,6 +7,7 @@ "https://api.github.com/repos/hacs-test-org/integration-basic/releases": 1, "https://brands.home-assistant.io/domains.json": 1, "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/custom_components/example/manifest.json": 1, - "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/hacs.json": 1 + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/hacs.json": 1, + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json": 1 } } \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-no-releases.json b/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-no-releases.json index 4cbc668ecb2..4534dda20e4 100644 --- a/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-no-releases.json +++ b/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-no-releases.json @@ -7,6 +7,7 @@ "https://api.github.com/repos/hacs-test-org/integration-basic/releases": 1, "https://brands.home-assistant.io/domains.json": 1, "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/custom_components/example/manifest.json": 1, - "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/hacs.json": 1 + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/hacs.json": 1, + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json": 1 } } \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-releases-without-assets.json b/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-releases-without-assets.json index 79429850924..996578b43f2 100644 --- a/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-releases-without-assets.json +++ b/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-releases-without-assets.json @@ -7,6 +7,7 @@ "https://api.github.com/repos/hacs-test-org/integration-basic/releases": 1, "https://brands.home-assistant.io/domains.json": 1, "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/custom_components/example/manifest.json": 1, - "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/hacs.json": 1 + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/hacs.json": 1, + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json": 1 } } \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-valid-manifest.json b/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-valid-manifest.json index 84542d92a4d..ba56c30854d 100644 --- a/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-valid-manifest.json +++ b/tests/snapshots/api-usage/tests/action/test_hacs_action_integrationtest-hacs-action-integration-valid-manifest.json @@ -7,6 +7,7 @@ "https://api.github.com/repos/hacs-test-org/integration-basic/releases": 1, "https://brands.home-assistant.io/domains.json": 1, "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/custom_components/example/manifest.json": 1, - "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/hacs.json": 1 + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/hacs.json": 1, + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json": 1 } } \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-missing-spdx-id.json b/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-missing-spdx-id.json new file mode 100644 index 00000000000..231cd2223b1 --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-missing-spdx-id.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_license.py::test_repository_missing_spdx_id": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-no-license.json b/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-no-license.json new file mode 100644 index 00000000000..1cd33df0173 --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-no-license.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_license.py::test_repository_no_license": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-non-osi-license.json b/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-non-osi-license.json new file mode 100644 index 00000000000..fd513c1e859 --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-non-osi-license.json @@ -0,0 +1,10 @@ +{ + "tests/validate/test_license.py::test_repository_non_osi_license": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1, + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-osi-approved-license-gpl-3-0.json b/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-osi-approved-license-gpl-3-0.json new file mode 100644 index 00000000000..7eaab3e9b73 --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-osi-approved-license-gpl-3-0.json @@ -0,0 +1,10 @@ +{ + "tests/validate/test_license.py::test_repository_osi_approved_license[GPL-3.0]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1, + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-osi-approved-license-mit.json b/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-osi-approved-license-mit.json new file mode 100644 index 00000000000..770f400ccc6 --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-osi-approved-license-mit.json @@ -0,0 +1,10 @@ +{ + "tests/validate/test_license.py::test_repository_osi_approved_license[MIT]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1, + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-unrecognized-license.json b/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-unrecognized-license.json new file mode 100644 index 00000000000..f24cf187a52 --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_licensetest-repository-unrecognized-license.json @@ -0,0 +1,9 @@ +{ + "tests/validate/test_license.py::test_repository_unrecognized_license": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/validate/test_licensetest-spdx-license-list-fetch-failure.json b/tests/snapshots/api-usage/tests/validate/test_licensetest-spdx-license-list-fetch-failure.json new file mode 100644 index 00000000000..e5d84100147 --- /dev/null +++ b/tests/snapshots/api-usage/tests/validate/test_licensetest-spdx-license-list-fetch-failure.json @@ -0,0 +1,10 @@ +{ + "tests/validate/test_license.py::test_spdx_license_list_fetch_failure": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1, + "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json": 1 + } +} \ No newline at end of file diff --git a/tests/validate/test_license.py b/tests/validate/test_license.py new file mode 100644 index 00000000000..0d90a5ddc3d --- /dev/null +++ b/tests/validate/test_license.py @@ -0,0 +1,75 @@ +from unittest.mock import MagicMock + +import pytest + +from custom_components.hacs.validate.license import SPDX_LICENSE_LIST_URL, Validator + +from tests.common import MockedResponse, ResponseMocker + + +async def test_repository_no_license(repository): + repository.repository_object = MagicMock() + repository.repository_object.attributes = {"license": None} + check = Validator(repository) + await check.execute_validation() + assert check.failed + + +async def test_repository_unrecognized_license(repository): + repository.repository_object = MagicMock() + repository.repository_object.attributes = { + "license": {"key": "other", "name": "Other", "spdx_id": "NOASSERTION"}, + } + check = Validator(repository) + await check.execute_validation() + assert check.failed + + +async def test_repository_missing_spdx_id(repository): + repository.repository_object = MagicMock() + repository.repository_object.attributes = { + "license": {"key": "other", "name": "Other"}, + } + check = Validator(repository) + await check.execute_validation() + assert check.failed + + +async def test_repository_non_osi_license(repository): + repository.repository_object = MagicMock() + repository.repository_object.attributes = { + "license": { + "key": "cc0-1.0", + "name": "Creative Commons Zero v1.0 Universal", + "spdx_id": "CC0-1.0", + }, + } + check = Validator(repository) + await check.execute_validation() + assert check.failed + + +async def test_spdx_license_list_fetch_failure(repository, response_mocker: ResponseMocker): + response_mocker.add(SPDX_LICENSE_LIST_URL, MockedResponse(status=500)) + repository.repository_object = MagicMock() + repository.repository_object.attributes = { + "license": {"key": "mit", "name": "MIT License", "spdx_id": "MIT"}, + } + check = Validator(repository) + await check.execute_validation() + assert check.failed + + +@pytest.mark.parametrize("spdx_id", ["MIT", "GPL-3.0"]) +async def test_repository_osi_approved_license(repository, spdx_id): + repository.repository_object = MagicMock() + repository.repository_object.attributes = { + "license": { + "key": spdx_id.lower(), + "name": f"License {spdx_id}", + "spdx_id": spdx_id, + }, + } + check = Validator(repository) + await check.execute_validation() + assert not check.failed