blob: 3fb88bbeece25077aa59383a54eb663dda97962e [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 .asf.yaml GitHub rulesets feature."""
import json
from types import SimpleNamespace
from typing import Any
import asfyaml.asfyaml
import asfyaml.dataobjects
import pytest
from github import UnknownObjectException
from asfyaml.feature.github.rulesets import COPILOT_RULESET_NAME, rulesets as configure_rulesets
from helpers import YamlTest
# Set .asf.yaml to debug mode
asfyaml.asfyaml.DEBUG = True
valid_rulesets = YamlTest(
None,
None,
"""
github:
rulesets:
- name: "Default branch checks"
target: branch
enforcement: active
conditions:
ref_name:
include:
- "~DEFAULT_BRANCH"
exclude: []
rules:
- type: required_status_checks
parameters:
strict_required_status_checks_policy: true
required_status_checks: []
""",
)
valid_rulesets_convenience = YamlTest(
None,
None,
"""
github:
rulesets:
- name: "Branch Protection"
type: branch
branches:
includes:
- "main"
excludes: []
required_signatures: true
required_linear_history: true
required_conversation_resolution: true
required_pull_request_reviews:
dismiss_stale_reviews: true
require_last_push_approval: false
require_code_owner_reviews: true
required_approving_review_count: 2
required_status_checks:
- name: "gh-infra/jenkins"
app_slug: -1
""",
)
invalid_rulesets_not_sequence = YamlTest(
asfyaml.asfyaml.ASFYAMLException,
"when expecting a sequence",
"""
github:
rulesets:
name: "Not a list"
""",
)
invalid_rulesets_convenience_bad_bool = YamlTest(
asfyaml.asfyaml.ASFYAMLException,
"required_signatures must be a boolean",
"""
github:
rulesets:
- name: "Branch Protection"
required_signatures: "yes"
""",
)
def _build_ruleset_payload(name: str) -> dict[str, Any]:
return {
"name": name,
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": [],
}
},
"rules": [
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": True,
"required_status_checks": [],
},
}
],
}
class FakeRequester:
def __init__(
self,
rulesets: list[dict[str, Any]] | None = None,
*,
list_status: int = 200,
post_status: int = 201,
put_status: int = 200,
delete_status: int = 204,
):
self.rulesets = rulesets or []
self.list_status = list_status
self.post_status = post_status
self.put_status = put_status
self.delete_status = delete_status
self.calls: list[dict[str, Any]] = []
def requestJson(self, method: str, url: str, input: dict[str, Any] | None = None): # noqa: N802
self.calls.append({"method": method, "url": url, "input": input})
error_body = json.dumps({"message": "Validation Failed", "errors": [{"field": "rules", "code": "invalid"}]})
match method:
case "GET":
return self.list_status, {}, json.dumps(self.rulesets)
case "POST":
return self.post_status, {}, json.dumps({**(input or {}), "id": 999}) if self.post_status == 201 else error_body
case "PUT":
return self.put_status, {}, json.dumps(input or {}) if self.put_status == 200 else error_body
case "DELETE":
return self.delete_status, {}, "" if self.delete_status == 204 else error_body
case _:
raise ValueError(f"FakeRequester: unexpected HTTP method '{method}'")
class FakeFeature:
def __init__(
self,
*,
yaml: dict[str, Any],
previous_yaml: dict[str, Any],
requester: FakeRequester,
gh: Any | None = None,
noop_enabled: bool = False,
):
self.yaml = yaml
self.previous_yaml = previous_yaml
self.repository = SimpleNamespace(org_id="apache", name="infrastructure-asfyaml")
self.ghrepo = SimpleNamespace(_requester=requester)
self.gh = gh
self._noop_enabled = noop_enabled
self.instance = SimpleNamespace(environments_enabled={"production", "github_rulesets"})
def noop(self, directive: str) -> bool:
if self._noop_enabled:
print(f"[github::{directive}] Not applying changes, noop mode active.")
return True
return False
def test_basic_yaml(test_repo: asfyaml.dataobjects.Repository):
print("[github] Testing rulesets")
tests_to_run = (
valid_rulesets,
valid_rulesets_convenience,
invalid_rulesets_not_sequence,
invalid_rulesets_convenience_bad_bool,
)
for test in tests_to_run:
with test.ctx():
a = asfyaml.asfyaml.ASFYamlInstance(
repo=test_repo, committer="humbedooh", config_data=test.yaml, branch=asfyaml.dataobjects.DEFAULT_BRANCH
)
a.environments_enabled.add("noop")
a.environments_enabled.add("github_rulesets")
a.no_cache = True
a.run_parts()
def test_rulesets_create_new_ruleset():
payload = _build_ruleset_payload("Default branch checks")
requester = FakeRequester()
feature = FakeFeature(
yaml={"rulesets": [payload]},
previous_yaml={},
requester=requester,
)
configure_rulesets(feature)
assert [call["method"] for call in requester.calls] == ["GET", "POST"]
assert requester.calls[1]["url"] == "/repos/apache/infrastructure-asfyaml/rulesets"
assert requester.calls[1]["input"] == payload
def test_rulesets_convenience_syntax_creates_ruleset():
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Branch Protection",
"type": "branch",
"branches": {"includes": ["main"], "excludes": []},
"required_signatures": True,
"required_linear_history": True,
"required_conversation_resolution": True,
"required_pull_request_reviews": {
"dismiss_stale_reviews": True,
"require_last_push_approval": False,
"require_code_owner_reviews": True,
"required_approving_review_count": 2,
},
"required_status_checks": [{"name": "gh-infra/jenkins", "app_slug": -1}],
}
]
},
previous_yaml={},
requester=requester,
)
configure_rulesets(feature)
assert [call["method"] for call in requester.calls] == ["GET", "POST"]
payload = requester.calls[1]["input"]
assert payload["name"] == "Branch Protection"
assert payload["target"] == "branch"
assert payload["enforcement"] == "active"
assert payload["conditions"]["ref_name"]["include"] == ["refs/heads/main"]
assert payload["conditions"]["ref_name"]["exclude"] == []
rule_types = [rule["type"] for rule in payload["rules"]]
assert "deletion" in rule_types
assert "non_fast_forward" in rule_types
assert "required_signatures" in rule_types
assert "required_linear_history" in rule_types
assert "pull_request" in rule_types
assert "required_status_checks" in rule_types
status_rule = next(rule for rule in payload["rules"] if rule["type"] == "required_status_checks")
assert status_rule["parameters"]["strict_required_status_checks_policy"] is False
def test_rulesets_convenience_resolves_bypass_team_and_app_slug():
def get_team_by_slug(slug: str):
if slug not in {"infra"}:
raise UnknownObjectException(404, {"message": "Not Found"}, {})
return SimpleNamespace(id={"infra": 202}[slug])
def get_app(slug: str):
if slug not in {"jenkins"}:
raise UnknownObjectException(404, {"message": "Not Found"}, {})
return SimpleNamespace(id={"jenkins": 303}[slug])
fake_gh = SimpleNamespace(
get_organization=lambda _org: SimpleNamespace(get_team_by_slug=get_team_by_slug),
get_app=get_app,
)
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Branch Protection",
"type": "branch",
"required_signatures": True,
"bypass_teams": ["infra"],
"required_status_checks": [{"name": "gh-infra/jenkins", "app_slug": "jenkins"}],
}
]
},
previous_yaml={},
requester=requester,
gh=fake_gh,
)
configure_rulesets(feature)
post_call = next(call for call in requester.calls if call["method"] == "POST")
payload = post_call["input"]
assert payload["bypass_actors"] == [
{"actor_id": 202, "actor_type": "Team", "bypass_mode": "always"},
]
status_rule = next(rule for rule in payload["rules"] if rule["type"] == "required_status_checks")
assert status_rule["parameters"]["required_status_checks"] == [
{"context": "gh-infra/jenkins", "integration_id": 303}
]
def test_rulesets_convenience_unknown_bypass_team_raises():
def raise_unknown(_slug: str):
raise UnknownObjectException(404, {"message": "Not Found"}, {})
fake_gh = SimpleNamespace(
get_organization=lambda _org: SimpleNamespace(get_team_by_slug=raise_unknown),
)
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Branch Protection",
"type": "branch",
"bypass_teams": ["unknown-team"],
}
]
},
previous_yaml={},
requester=requester,
gh=fake_gh,
)
with pytest.raises(Exception, match="Unable to resolve bypass_team 'unknown-team' to team ID") as exc_info:
configure_rulesets(feature)
assert isinstance(exc_info.value.__cause__, UnknownObjectException)
def test_rulesets_convenience_unknown_app_slug_raises():
def raise_unknown(_slug: str):
raise UnknownObjectException(404, {"message": "Not Found"}, {})
fake_gh = SimpleNamespace(get_app=raise_unknown)
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Branch Protection",
"type": "branch",
"required_status_checks": [{"name": "gh-infra/jenkins", "app_slug": "unknown-app"}],
}
]
},
previous_yaml={},
requester=requester,
gh=fake_gh,
)
with pytest.raises(Exception, match="Unable to resolve app_slug 'unknown-app' to integration_id") as exc_info:
configure_rulesets(feature)
assert isinstance(exc_info.value.__cause__, UnknownObjectException)
@pytest.mark.parametrize(
("ruleset_type", "key", "pattern", "expected"),
[
# branches + branch target: bare name gets refs/heads/ prefix
("branch", "branches", "main", "refs/heads/main"),
("branch", "branches", "release/*", "refs/heads/release/*"),
# branches + tag target: bare name gets refs/tags/ prefix
("tag", "branches", "rel/*", "refs/tags/rel/*"),
("tag", "branches", "v*.*.*", "refs/tags/v*.*.*"),
# already-absolute and tilde patterns are left untouched regardless of target
("branch", "branches", "refs/heads/main", "refs/heads/main"),
("branch", "branches", "~DEFAULT_BRANCH", "~DEFAULT_BRANCH"),
("tag", "branches", "refs/tags/v1.*", "refs/tags/v1.*"),
# refs: always passed through as-is
("branch", "refs", "refs/heads/main", "refs/heads/main"),
("tag", "refs", "refs/tags/rel/*", "refs/tags/rel/*"),
("branch", "refs", "main", "main"),
],
)
def test_rulesets_ref_name_condition_prefixing(ruleset_type: str, key: str, pattern: str, expected: str):
requester = FakeRequester()
feature = FakeFeature(
yaml={"rulesets": [{"name": "R", "type": ruleset_type, key: {"includes": [pattern]}}]},
previous_yaml={},
requester=requester,
)
configure_rulesets(feature)
payload = requester.calls[1]["input"]
assert payload["conditions"]["ref_name"]["include"] == [expected]
def test_rulesets_convenience_tag_restrict_force_push_rule():
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Release tags",
"type": "tag",
"branches": {"includes": ["v*.*.*"], "excludes": []},
"restrict_force_push": True,
}
]
},
previous_yaml={},
requester=requester,
)
configure_rulesets(feature)
payload = requester.calls[1]["input"]
assert payload["target"] == "tag"
rule_types = [rule["type"] for rule in payload["rules"]]
assert "deletion" in rule_types
assert "non_fast_forward" in rule_types
def test_rulesets_convenience_minimal_config_includes_safety_rules():
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Safety defaults only",
"type": "branch",
}
]
},
previous_yaml={},
requester=requester,
)
configure_rulesets(feature)
payload = requester.calls[1]["input"]
rule_types = [rule["type"] for rule in payload["rules"]]
assert rule_types == ["deletion", "non_fast_forward"]
def test_rulesets_convenience_restrict_deletion_false_disables_rule():
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Branch Protection",
"type": "branch",
"restrict_deletion": False,
"required_signatures": True,
}
]
},
previous_yaml={},
requester=requester,
)
configure_rulesets(feature)
payload = requester.calls[1]["input"]
rule_types = [rule["type"] for rule in payload["rules"]]
assert "deletion" not in rule_types
assert "non_fast_forward" in rule_types
def test_rulesets_convenience_restrict_force_push_false_disables_rule():
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Branch Protection",
"type": "branch",
"restrict_force_push": False,
"required_signatures": True,
}
]
},
previous_yaml={},
requester=requester,
)
configure_rulesets(feature)
payload = requester.calls[1]["input"]
rule_types = [rule["type"] for rule in payload["rules"]]
assert "deletion" in rule_types
assert "non_fast_forward" not in rule_types
def test_rulesets_convenience_both_safety_rules_false_allows_empty_rules():
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Branch Protection",
"type": "branch",
"restrict_deletion": False,
"restrict_force_push": False,
}
]
},
previous_yaml={},
requester=requester,
)
configure_rulesets(feature)
payload = requester.calls[1]["input"]
assert payload["rules"] == []
@pytest.mark.parametrize(
("field_name", "field_value"),
[
("restrict_deletion", True),
("restrict_deletion", "true"),
("restrict_force_push", True),
("restrict_force_push", "true"),
],
)
def test_rulesets_convenience_allow_explicit_safety_true(field_name: str, field_value: bool | str):
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Branch Protection",
"type": "branch",
field_name: field_value,
"required_signatures": True,
}
]
},
previous_yaml={},
requester=requester,
)
configure_rulesets(feature)
payload = requester.calls[1]["input"]
rule_types = [rule["type"] for rule in payload["rules"]]
assert "deletion" in rule_types
assert "non_fast_forward" in rule_types
@pytest.mark.parametrize(
"field_name",
["restrict_deletion", "restrict_force_push"],
)
def test_rulesets_convenience_reject_invalid_safety_type(field_name: str):
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Branch Protection",
"type": "branch",
field_name: "no",
"required_signatures": True,
}
]
},
previous_yaml={},
requester=requester,
)
with pytest.raises(Exception, match=rf"{field_name} must be a boolean"):
configure_rulesets(feature)
def test_rulesets_convenience_conversation_resolution_false_does_not_add_pull_request_rule():
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Branch Protection",
"type": "branch",
"required_signatures": True,
"required_conversation_resolution": False,
}
]
},
previous_yaml={},
requester=requester,
)
configure_rulesets(feature)
payload = requester.calls[1]["input"]
rule_types = [rule["type"] for rule in payload["rules"]]
assert "required_signatures" in rule_types
assert "pull_request" not in rule_types
@pytest.mark.parametrize(
("status", "match"),
[
(404, "not found or not accessible"),
(500, "GitHub server error"),
(403, "Unexpected response while listing rulesets: HTTP 403"),
],
)
def test_rulesets_list_error_status_raises(status: int, match: str):
requester = FakeRequester(list_status=status)
feature = FakeFeature(
yaml={"rulesets": [_build_ruleset_payload("Default branch checks")]},
previous_yaml={},
requester=requester,
)
with pytest.raises(Exception, match=match):
configure_rulesets(feature)
def test_rulesets_list_unexpected_payload_raises():
requester = FakeRequester()
requester.rulesets = {} # type: ignore[assignment] — force a non-list body
feature = FakeFeature(
yaml={"rulesets": [_build_ruleset_payload("Default branch checks")]},
previous_yaml={},
requester=requester,
)
with pytest.raises(Exception, match="expected a list, got dict"):
configure_rulesets(feature)
@pytest.mark.parametrize(
("post_status", "match"),
[
(404, "not found or not accessible"),
(422, "Validation failed while creating ruleset.*rules.*invalid"),
(500, "GitHub server error while creating ruleset"),
(503, "Unexpected response while creating ruleset 'Default branch checks': HTTP 503"),
],
)
def test_rulesets_add_error_status_raises(post_status: int, match: str):
requester = FakeRequester(post_status=post_status)
feature = FakeFeature(
yaml={"rulesets": [_build_ruleset_payload("Default branch checks")]},
previous_yaml={},
requester=requester,
)
with pytest.raises(Exception, match=match):
configure_rulesets(feature)
@pytest.mark.parametrize(
("put_status", "match"),
[
(404, "Ruleset 'Default branch checks' \\(22\\) not found"),
(422, "Validation failed while updating ruleset 'Default branch checks' \\(22\\).*rules.*invalid"),
(500, "GitHub server error while updating ruleset 'Default branch checks' \\(22\\)"),
(503, "Unexpected response while updating ruleset 'Default branch checks' \\(22\\): HTTP 503"),
],
)
def test_rulesets_update_error_status_raises(put_status: int, match: str):
payload = _build_ruleset_payload("Default branch checks")
requester = FakeRequester(rulesets=[{"id": 22, "name": payload["name"]}], put_status=put_status)
feature = FakeFeature(
yaml={"rulesets": [payload]},
previous_yaml={"rulesets": [payload]},
requester=requester,
)
with pytest.raises(Exception, match=match):
configure_rulesets(feature)
@pytest.mark.parametrize(
("delete_status", "match"),
[
(404, "Ruleset 'Default branch checks' \\(31\\) not found"),
(500, "GitHub server error while deleting ruleset 'Default branch checks' \\(31\\)"),
(503, "Unexpected response while deleting ruleset 'Default branch checks' \\(31\\): HTTP 503"),
],
)
def test_rulesets_delete_error_status_raises(delete_status: int, match: str):
existing = [{"id": 31, "name": "Default branch checks"}]
requester = FakeRequester(rulesets=existing, delete_status=delete_status)
feature = FakeFeature(
yaml={},
previous_yaml={"rulesets": [_build_ruleset_payload("Default branch checks")]},
requester=requester,
)
with pytest.raises(Exception, match=match):
configure_rulesets(feature)
def test_rulesets_update_existing_ruleset():
payload = _build_ruleset_payload("Default branch checks")
requester = FakeRequester(rulesets=[{"id": 22, "name": payload["name"], "rules": []}])
feature = FakeFeature(
yaml={"rulesets": [payload]},
previous_yaml={"rulesets": [payload]},
requester=requester,
)
configure_rulesets(feature)
assert [call["method"] for call in requester.calls] == ["GET", "PUT"]
assert requester.calls[1]["url"] == "/repos/apache/infrastructure-asfyaml/rulesets/22"
assert requester.calls[1]["input"] == payload
def test_rulesets_removed_section_deletes_previously_managed_rulesets():
existing = [{"id": 31, "name": "Default branch checks", "rules": []}]
requester = FakeRequester(rulesets=existing)
feature = FakeFeature(
yaml={},
previous_yaml={"rulesets": [_build_ruleset_payload("Default branch checks")]},
requester=requester,
)
configure_rulesets(feature)
assert [call["method"] for call in requester.calls] == ["GET", "DELETE"]
assert requester.calls[1]["url"] == "/repos/apache/infrastructure-asfyaml/rulesets/31"
def test_rulesets_noop_mode_does_not_call_api(capsys):
payload = _build_ruleset_payload("Default branch checks")
requester = FakeRequester()
feature = FakeFeature(
yaml={"rulesets": [payload]},
previous_yaml={},
requester=requester,
noop_enabled=True,
)
configure_rulesets(feature)
captured = capsys.readouterr()
assert "noop mode active" in captured.out
assert requester.calls == []
def test_rulesets_conflict_with_copilot_convenience_block():
payload = _build_ruleset_payload(COPILOT_RULESET_NAME)
requester = FakeRequester()
feature = FakeFeature(
yaml={"rulesets": [payload], "copilot_code_review": {"enabled": True}},
previous_yaml={},
requester=requester,
)
with pytest.raises(Exception, match="Cannot configure Copilot Code Review via both"):
configure_rulesets(feature)
assert requester.calls == []
def test_rulesets_conflict_with_copilot_when_ruleset_contains_copilot_rule():
payload = {
"name": "Custom copilot ruleset",
"target": "branch",
"enforcement": "active",
"conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}},
"rules": [{"type": "copilot_code_review", "parameters": {"review_draft_pull_requests": False}}],
}
requester = FakeRequester()
feature = FakeFeature(
yaml={"rulesets": [payload], "copilot_code_review": {"enabled": True}},
previous_yaml={},
requester=requester,
)
with pytest.raises(Exception, match="Cannot configure Copilot Code Review via both"):
configure_rulesets(feature)
assert requester.calls == []
def test_rulesets_duplicate_names_raise():
payload = _build_ruleset_payload("Duplicate name")
requester = FakeRequester()
feature = FakeFeature(
yaml={"rulesets": [payload, payload]},
previous_yaml={},
requester=requester,
)
with pytest.raises(Exception, match=r"Duplicate ruleset name in github\.rulesets"):
configure_rulesets(feature)
def test_rulesets_mixed_raw_and_convenience_keys_raise():
requester = FakeRequester()
feature = FakeFeature(
yaml={
"rulesets": [
{
"name": "Branch Protection",
"target": "branch",
"rules": [{"type": "required_signatures"}],
"required_signatures": True,
}
]
},
previous_yaml={},
requester=requester,
)
with pytest.raises(Exception, match="Raw ruleset payload cannot mix convenience syntax keys"):
configure_rulesets(feature)