diff --git a/.github/workflows/audits.yml b/.github/workflows/audits.yml index 8fcd66c..352176c 100644 --- a/.github/workflows/audits.yml +++ b/.github/workflows/audits.yml @@ -126,7 +126,7 @@ jobs: - name: Install faircode + pytest run: | python -m pip install --upgrade pip - pip install -e ".[excel,parquet,proxy,mcp]" pytest pytest-cov + pip install -e ".[test,excel,parquet,proxy,mcp]" # One run: the whole suite with line coverage printed at the end. A test failure fails the # job; the coverage figure itself is informational, not a gate (#932). diff --git a/pyproject.toml b/pyproject.toml index 3640634..9847613 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ parquet = ["pyarrow>=10.0"] proxy = ["scipy>=1.10"] benchmark = ["scikit-learn>=1.8.0", "pyyaml>=6.0", "fairlearn>=0.14.0", "matplotlib>=3.10"] mcp = ["mcp>=2.0"] -test = ["pytest>=8.0", "pytest-cov>=5.0", "certifi>=2023.1.1"] # certifi: scripts/check_external_links.py trust store fallback +test = ["pytest>=8.0", "pytest-cov>=5.0", "certifi>=2023.1.1", "pyyaml>=6.0"] # certifi: scripts/check_external_links.py trust store fallback; pyyaml: workflow safeguard tests browser = ["playwright>=1.40"] # tests/browser; also run `python -m playwright install chromium` [project.urls] diff --git a/tests/test_scheduled_workflow_safeguards.py b/tests/test_scheduled_workflow_safeguards.py new file mode 100644 index 0000000..2c8f062 --- /dev/null +++ b/tests/test_scheduled_workflow_safeguards.py @@ -0,0 +1,66 @@ +from pathlib import Path + +import pytest +import yaml + + +WORKFLOWS_DIR = Path(__file__).parents[1] / ".github/workflows" +EXPECTED_SCHEDULED_PERMISSIONS = { + "codeowners-access.yml": {"contents": "read"}, + "codeql.yml": { + "actions": "read", + "contents": "read", + "security-events": "write", + }, + "external-links.yml": {"contents": "read", "issues": "write"}, + "traction-live.yml": {"contents": "read", "issues": "write"}, +} + + +def _scheduled_workflows(): + workflows = [] + for path in sorted(WORKFLOWS_DIR.glob("*.yml")): + workflow = yaml.load(path.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + if "schedule" in workflow.get("on", {}): + workflows.append((path, workflow)) + return workflows + + +SCHEDULED_WORKFLOWS = _scheduled_workflows() + + +def test_scheduled_workflows_have_explicit_minimal_permissions(): + actual = {path.name for path, _ in SCHEDULED_WORKFLOWS} + assert actual == set(EXPECTED_SCHEDULED_PERMISSIONS) + + for path, workflow in SCHEDULED_WORKFLOWS: + permissions = workflow.get("permissions") + assert permissions == EXPECTED_SCHEDULED_PERMISSIONS[path.name] + assert permissions["contents"] == "read" + + +@pytest.mark.parametrize( + "path,workflow", + SCHEDULED_WORKFLOWS, + ids=[path.name for path, _ in SCHEDULED_WORKFLOWS], +) +def test_scheduled_workflows_have_concurrency_controls(path, workflow): + concurrency = workflow.get("concurrency") + assert isinstance(concurrency, dict), path.name + assert concurrency.get("group", "").strip(), path.name + assert "cancel-in-progress" in concurrency, path.name + + +@pytest.mark.parametrize( + "path,workflow", + SCHEDULED_WORKFLOWS, + ids=[path.name for path, _ in SCHEDULED_WORKFLOWS], +) +def test_scheduled_workflow_jobs_have_timeouts(path, workflow): + jobs = workflow.get("jobs") + assert isinstance(jobs, dict) and jobs, path.name + + for job_name, job in jobs.items(): + timeout = job.get("timeout-minutes") + assert timeout is not None, f"{path.name}:{job_name} has no timeout" + assert 1 <= int(timeout) <= 360, f"{path.name}:{job_name} has invalid timeout"