blob: c2e0635b71a128f9afb717340d06cc8fe804b9b6 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
"""Unit tests for the .asf.yaml `project` (ATR metadata) feature"""
import json
import pytest
import asfyaml.asfyaml
import asfyaml.dataobjects
from asfyaml.feature.project import _doap_metadata, _exchange_token_for_jwt, _parse_doap, _validate_doap_url
from helpers import YamlTest
# The ATR project's own description, reused across the DOAP fixture and assertions.
ATR_DESCRIPTION = (
"ATR is a platform through which committees of Apache Software Foundation (ASF) projects can make "
'official ASF software releases. Official ASF releases are endorsed as an "act of the Foundation". '
"It is therefore important that the foundation - its board, members, committees, and contributors - "
"and the general public can have confidence in the releases."
)
# A DOAP file for the ATR project, covering every field we map. DOAP has no
# equivalent of lifecycle_page or committee, so those are not represented here.
SAMPLE_DOAP = f"""<?xml version="1.0"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://usefulinc.com/ns/doap#"
xmlns:asfext="http://projects.apache.org/ns/asfext#">
<Project rdf:about="https://tooling.apache.org/trusted-releases.html">
<name>Apache Trusted Releases</name>
<shortdesc>A platform for making official ASF software releases.</shortdesc>
<description>{ATR_DESCRIPTION}</description>
<homepage rdf:resource="https://tooling.apache.org/trusted-releases.html"/>
<bug-database rdf:resource="https://github.com/apache/tooling-trusted-releases/issues"/>
<download-page rdf:resource="https://github.com/apache/tooling-trusted-releases"/>
<mailing-list rdf:resource="https://tooling.apache.org/volunteer.html"/>
<programming-language>python</programming-language>
<category rdf:resource="http://projects.apache.org/category/build-management"/>
<repository>
<GitRepository>
<location rdf:resource="git+ssh://git@github.com:apache/tooling-trusted-releases.git"/>
<browse rdf:resource="https://github.com/apache/tooling-trusted-releases"/>
</GitRepository>
</repository>
<asfext:implements>
<asfext:Standard>
<asfext:title>OWASP ASVS</asfext:title>
<asfext:url rdf:resource="https://owasp.org/www-project-application-security-verification-standard/"/>
</asfext:Standard>
</asfext:implements>
</Project>
</rdf:RDF>""".encode()
valid_metadata = YamlTest(
None,
None,
"""
project:
metadata:
key: tooling-trusted-releases
committee: tooling
name: Apache Trusted Releases
short_description: A platform for making official ASF software releases.
homepage: https://tooling.apache.org/trusted-releases.html
lifecycle_page: https://tooling.apache.org/trusted-releases.html
download_page: https://github.com/apache/tooling-trusted-releases
bug_database: https://github.com/apache/tooling-trusted-releases/issues
mailing_lists: https://tooling.apache.org/volunteer.html
repositories:
- git+ssh://git@github.com:apache/tooling-trusted-releases.git
standards:
- https://owasp.org/www-project-application-security-verification-standard/
categories:
- build-management
programming_languages:
- python
""",
)
valid_metadata_with_policy = YamlTest(
None,
None,
"""
project:
metadata:
key: tooling-trusted-releases
committee: tooling
name: Apache Trusted Releases
policy:
vote_recipients:
to: private@tooling.apache.org
cc:
- dev@tooling.apache.org
announce_recipients:
to: announce@apache.org
""",
)
valid_metadata_with_doap = YamlTest(
None,
None,
"""
project:
metadata:
key: tooling-trusted-releases
committee: tooling
doap: https://tooling.apache.org/doap.rdf
""",
)
# metadata is a required key
invalid_missing_metadata = YamlTest(
asfyaml.asfyaml.ASFYAMLException,
"required key",
"""
project:
features:
atr_sync: true
""",
)
# key and committee are required inside metadata
invalid_missing_key = YamlTest(
asfyaml.asfyaml.ASFYAMLException,
"required key",
"""
project:
metadata:
committee: tooling
name: Apache Trusted Releases
""",
)
# unexpected key inside metadata
invalid_unknown_metadata_key = YamlTest(
asfyaml.asfyaml.ASFYAMLException,
"unexpected key not in schema 'foobar'",
"""
project:
metadata:
key: tooling-trusted-releases
committee: tooling
name: Apache Trusted Releases
foobar: nope
""",
)
valid_full_policy = YamlTest(
None,
None,
"""
project:
metadata:
key: tooling-trusted-releases
committee: tooling
name: Apache Trusted Releases
policy:
announce_release_subject: "[ANNOUNCE] Apache Foo released"
binary_artifact_paths:
- dist/*.jar
file_tag_mappings:
sources:
- "*.tar.gz"
binaries:
- "*.jar"
github_repository_branch: main
github_vote_workflow_path:
- .github/workflows/vote.yml
license_check_mode: RAT
vote_recipients:
to: private@tooling.apache.org
min_hours: 72
preserve_download_files: true
vote_mode: email
""",
)
# vote_mode only accepts manual/email/trusted
invalid_bad_vote_mode = YamlTest(
asfyaml.asfyaml.ASFYAMLException,
"when expecting one of: manual, email, trusted",
"""
project:
metadata:
key: tooling-trusted-releases
committee: tooling
name: Apache Trusted Releases
policy:
vote_mode: telepathy
""",
)
# unexpected key inside policy
invalid_unknown_policy_key = YamlTest(
asfyaml.asfyaml.ASFYAMLException,
"unexpected key not in schema 'reviewers'",
"""
project:
metadata:
key: tooling-trusted-releases
committee: tooling
name: Apache Trusted Releases
policy:
reviewers:
to: someone@tooling.apache.org
""",
)
def test_schema_validation(test_repo: asfyaml.dataobjects.Repository):
print("[project] Testing schema validation")
tests_to_run = (
valid_metadata,
valid_metadata_with_policy,
valid_full_policy,
valid_metadata_with_doap,
invalid_missing_metadata,
invalid_missing_key,
invalid_unknown_metadata_key,
invalid_bad_vote_mode,
invalid_unknown_policy_key,
)
for test in tests_to_run:
with test.ctx():
a = asfyaml.asfyaml.ASFYamlInstance(
repo=test_repo, committer="arm", config_data=test.yaml, branch=asfyaml.dataobjects.DEFAULT_BRANCH
)
a.environments_enabled.add("noop")
a.no_cache = True
a.run_parts(validate_only=True)
def test_doap_full_mapping():
metadata = _doap_metadata(SAMPLE_DOAP)
assert metadata == {
"name": "Apache Trusted Releases",
"description": ATR_DESCRIPTION,
"short_description": "A platform for making official ASF software releases.",
"homepage": "https://tooling.apache.org/trusted-releases.html",
"download_page": "https://github.com/apache/tooling-trusted-releases",
"bug_database": "https://github.com/apache/tooling-trusted-releases/issues",
"mailing_lists": "https://tooling.apache.org/volunteer.html",
"programming_languages": ["python"],
"categories": ["build-management"],
"repositories": ["git+ssh://git@github.com:apache/tooling-trusted-releases.git"],
"standards": ["https://owasp.org/www-project-application-security-verification-standard/"],
}
def test_doap_partial_only_includes_present_fields():
doap = b"""<?xml version="1.0"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://usefulinc.com/ns/doap#">
<Project>
<name>Apache Trusted Releases</name>
<homepage rdf:resource="https://tooling.apache.org/trusted-releases.html"/>
</Project>
</rdf:RDF>"""
assert _doap_metadata(doap) == {
"name": "Apache Trusted Releases",
"homepage": "https://tooling.apache.org/trusted-releases.html",
}
def test_doap_collects_multiple_repos_and_standards():
# Mirrors real DOAP files (e.g. Maven's two repos, FOP's SVN repo and many standards):
# repositories may be Git or SVN and appear more than once, and each Standard carries
# body/id we ignore in favour of the url.
doap = b"""<?xml version="1.0"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://usefulinc.com/ns/doap#"
xmlns:asfext="http://projects.apache.org/ns/asfext#">
<Project>
<name>Apache Example</name>
<repository>
<SVNRepository>
<location rdf:resource="https://svn.apache.org/repos/asf/example/trunk/"/>
<browse rdf:resource="https://svn.apache.org/viewvc/example/trunk/"/>
</SVNRepository>
</repository>
<repository>
<GitRepository>
<location rdf:resource="https://gitbox.apache.org/repos/asf/example.git"/>
<browse rdf:resource="https://github.com/apache/example"/>
</GitRepository>
</repository>
<asfext:implements>
<asfext:Standard>
<asfext:title>Extensible Stylesheet Language (XSL-FO 1.1)</asfext:title>
<asfext:body>W3C</asfext:body>
<asfext:id>XSL 1.1</asfext:id>
<asfext:url rdf:resource="http://www.w3.org/TR/xsl11/"/>
</asfext:Standard>
</asfext:implements>
<asfext:implements>
<asfext:Standard>
<asfext:title>Portable Document Format (PDF 1.4)</asfext:title>
<asfext:body>Adobe Systems Incorporated</asfext:body>
<asfext:id>PDF 1.4</asfext:id>
<asfext:url rdf:resource="https://www.adobe.com/pdf/1.4"/>
</asfext:Standard>
</asfext:implements>
</Project>
</rdf:RDF>"""
assert _doap_metadata(doap) == {
"name": "Apache Example",
"repositories": [
"https://svn.apache.org/repos/asf/example/trunk/",
"https://gitbox.apache.org/repos/asf/example.git",
],
"standards": ["http://www.w3.org/TR/xsl11/", "https://www.adobe.com/pdf/1.4"],
}
def test_doap_missing_project_element_raises():
doap = b"""<?xml version="1.0"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://usefulinc.com/ns/doap#"></rdf:RDF>"""
with pytest.raises(Exception, match="No doap:Project element found"):
_doap_metadata(doap)
def test_doap_malformed_xml_raises():
with pytest.raises(Exception, match="Could not parse DOAP file"):
_doap_metadata(b"<rdf:RDF><not closed")
def test_validate_doap_url_allows_apache_and_raw_github_apache():
# apache.org and its subdomains, plus raw.githubusercontent.com/apache, are permitted.
_validate_doap_url("https://tooling.apache.org/doap.rdf")
_validate_doap_url("https://apache.org/doap.rdf")
_validate_doap_url("https://raw.githubusercontent.com/apache/tooling-trusted-releases/main/doap.rdf")
def test_validate_doap_url_rejects_github_com():
# github.com itself redirects to raw.githubusercontent.com, and we don't follow redirects.
with pytest.raises(Exception, match="host not allowed"):
_validate_doap_url("https://github.com/apache/tooling-trusted-releases/raw/main/doap.rdf")
def test_validate_doap_url_rejects_http_scheme():
with pytest.raises(Exception, match="must use https"):
_validate_doap_url("http://tooling.apache.org/doap.rdf")
def test_validate_doap_url_rejects_foreign_host():
with pytest.raises(Exception, match="host not allowed"):
_validate_doap_url("https://evil.example.com/doap.rdf")
def test_validate_doap_url_rejects_internal_metadata_endpoint():
with pytest.raises(Exception, match="host not allowed"):
_validate_doap_url("https://169.254.169.254/latest/meta-data/")
def test_validate_doap_url_rejects_raw_github_non_apache_org():
with pytest.raises(Exception, match="host not allowed"):
_validate_doap_url("https://raw.githubusercontent.com/someoneelse/project/main/doap.rdf")
def test_validate_doap_url_rejects_lookalike_host():
# A host that merely contains apache.org must not slip through.
with pytest.raises(Exception, match="host not allowed"):
_validate_doap_url("https://apache.org.evil.com/doap.rdf")
def test_parse_doap_downloads_then_maps(monkeypatch):
class FakeResponse:
ok = True
is_redirect = False
content = SAMPLE_DOAP
captured = {}
def fake_get(url, timeout=None, allow_redirects=True):
captured["url"] = url
captured["timeout"] = timeout
captured["allow_redirects"] = allow_redirects
return FakeResponse()
monkeypatch.setattr("asfyaml.feature.project.requests.get", fake_get)
metadata = _parse_doap("https://tooling.apache.org/doap.rdf")
assert captured["url"] == "https://tooling.apache.org/doap.rdf"
assert captured["allow_redirects"] is False
assert metadata["name"] == "Apache Trusted Releases"
def test_parse_doap_download_failure_raises(monkeypatch):
class FakeResponse:
ok = False
is_redirect = False
status_code = 404
monkeypatch.setattr(
"asfyaml.feature.project.requests.get", lambda url, timeout=None, allow_redirects=True: FakeResponse()
)
with pytest.raises(Exception, match="Could not download DOAP file"):
_parse_doap("https://tooling.apache.org/missing.rdf")
def test_parse_doap_redirect_rejected(monkeypatch):
class FakeResponse:
ok = True
is_redirect = True
status_code = 302
monkeypatch.setattr(
"asfyaml.feature.project.requests.get", lambda url, timeout=None, allow_redirects=True: FakeResponse()
)
with pytest.raises(Exception, match="returned a redirect"):
_parse_doap("https://tooling.apache.org/moved.rdf")
def test_parse_doap_rejects_disallowed_host_before_fetching(monkeypatch):
def explode(*args, **kwargs):
raise AssertionError("requests.get must not be called for a disallowed host")
monkeypatch.setattr("asfyaml.feature.project.requests.get", explode)
with pytest.raises(Exception, match="host not allowed"):
_parse_doap("https://evil.example.com/doap.rdf")
def test_noop_payload_shape(atr_repo: asfyaml.dataobjects.Repository, capsys):
yaml = """
project:
metadata:
key: tooling-trusted-releases
committee: tooling
name: Apache Trusted Releases
homepage: https://tooling.apache.org/trusted-releases.html
policy:
vote_recipients:
to: private@tooling.apache.org
"""
a = asfyaml.asfyaml.ASFYamlInstance(
repo=atr_repo, committer="arm", config_data=yaml, branch=asfyaml.dataobjects.DEFAULT_BRANCH
)
a.environments_enabled.add("noop")
a.no_cache = True
a.run_parts()
out = capsys.readouterr().out
payload = json.loads(out[out.index("{") : out.rindex("}") + 1])
assert payload["project_key"] == "tooling-trusted-releases"
assert payload["committee_key"] == "tooling"
assert "key" not in payload["project"]
assert "committee" not in payload["project"]
assert payload["project"]["name"] == "Apache Trusted Releases"
assert payload["policy"]["vote_recipients"]["to"] == "private@tooling.apache.org"
def test_noop_payload_policy_types(atr_repo: asfyaml.dataobjects.Repository, capsys):
yaml = """
project:
metadata:
key: tooling-trusted-releases
committee: tooling
name: Apache Trusted Releases
policy:
min_hours: 72
preserve_download_files: true
license_check_mode: RAT
binary_artifact_paths:
- dist/*.jar
file_tag_mappings:
sources:
- "*.tar.gz"
"""
a = asfyaml.asfyaml.ASFYamlInstance(
repo=atr_repo, committer="arm", config_data=yaml, branch=asfyaml.dataobjects.DEFAULT_BRANCH
)
a.environments_enabled.add("noop")
a.no_cache = True
a.run_parts()
out = capsys.readouterr().out
policy = json.loads(out[out.index("{") : out.rindex("}") + 1])["policy"]
assert policy["min_hours"] == 72 # int, not "72"
assert policy["preserve_download_files"] is True # bool, not "true"
assert policy["license_check_mode"] == "RAT"
assert policy["binary_artifact_paths"] == ["dist/*.jar"]
assert policy["file_tag_mappings"] == {"sources": ["*.tar.gz"]}
def test_committee_matching_repo_prefix_is_accepted(atr_repo: asfyaml.dataobjects.Repository, capsys):
# Repo "tooling-trusted-releases" accepts committee "tooling" (prefix before the hyphen).
yaml = """
project:
metadata:
key: tooling-test
committee: tooling
name: Apache Trusted Releases
"""
a = asfyaml.asfyaml.ASFYamlInstance(
repo=atr_repo, committer="arm", config_data=yaml, branch=asfyaml.dataobjects.DEFAULT_BRANCH
)
a.environments_enabled.add("noop")
a.no_cache = True
a.run_parts()
out = capsys.readouterr().out
payload = json.loads(out[out.index("{") : out.rindex("}") + 1])
assert payload["project_key"] == "tooling-test"
assert payload["committee_key"] == "tooling"
def test_exchange_token_for_jwt_posts_system_pat(monkeypatch):
# The config holds a system PAT; ATR swaps it for a JWT at /api/jwt/create.
captured = {}
class FakeResponse:
ok = True
@staticmethod
def json():
return {"asfuid": "system", "jwt": "a-short-lived-jwt"}
def fake_post(url, json=None, timeout=None):
captured["url"] = url
captured["json"] = json
return FakeResponse()
monkeypatch.setattr("asfyaml.feature.project.requests.post", fake_post)
jwt = _exchange_token_for_jwt("https://atr.example", "the-system-pat")
assert jwt == "a-short-lived-jwt"
assert captured["url"] == "https://atr.example/api/jwt/create"
assert captured["json"] == {"asfuid": "system", "pat": "the-system-pat"}
def test_exchange_token_for_jwt_failure_raises(monkeypatch):
class FakeResponse:
ok = False
status_code = 401
text = "unauthorized"
monkeypatch.setattr(
"asfyaml.feature.project.requests.post", lambda url, json=None, timeout=None: FakeResponse()
)
with pytest.raises(Exception, match="ATR token exchange failed"):
_exchange_token_for_jwt("https://atr.example", "bad-pat")
def test_exchange_token_for_jwt_missing_jwt_raises(monkeypatch):
class FakeResponse:
ok = True
text = '{"asfuid": "system"}'
@staticmethod
def json():
return {"asfuid": "system"}
monkeypatch.setattr(
"asfyaml.feature.project.requests.post", lambda url, json=None, timeout=None: FakeResponse()
)
with pytest.raises(Exception, match="returned no JWT"):
_exchange_token_for_jwt("https://atr.example", "the-system-pat")
def test_committee_not_matching_repo_is_rejected(atr_repo: asfyaml.dataobjects.Repository):
yaml = """
project:
metadata:
key: tooling-trusted-releases
committee: whimsy
name: Apache Trusted Releases
"""
a = asfyaml.asfyaml.ASFYamlInstance(
repo=atr_repo, committer="arm", config_data=yaml, branch=asfyaml.dataobjects.DEFAULT_BRANCH
)
a.environments_enabled.add("noop")
a.no_cache = True
with pytest.raises(asfyaml.asfyaml.ASFYAMLException, match="does not match repository name"):
a.run_parts()