blob: 275656a2d4806308a4156446412c47f2e39420b3 [file] [log] [blame]
# 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 ReleaseValidator base class."""
from __future__ import annotations
import hashlib
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from airflow_breeze.utils.release_validator import CheckType, ReleaseValidator, ValidationResult
class ConcreteValidator(ReleaseValidator):
"""Concrete implementation of ReleaseValidator for testing."""
def __init__(self, version: str, svn_path: Path, airflow_repo_root: Path, **kwargs):
super().__init__(version, svn_path, airflow_repo_root, **kwargs)
def get_distribution_name(self) -> str:
return "Test Distribution"
def get_svn_directory(self) -> Path:
return self.svn_path / self.version
def get_expected_files(self) -> list[str]:
return ["test-1.0.tar.gz", "test-1.0.tar.gz.asc", "test-1.0.tar.gz.sha512"]
def build_packages(self, source_dir: Path | None = None) -> bool:
return True
def validate_svn_files(self) -> ValidationResult:
return ValidationResult(check_type=CheckType.SVN, passed=True, message="OK")
def validate_reproducible_build(self) -> ValidationResult:
return ValidationResult(check_type=CheckType.REPRODUCIBLE_BUILD, passed=True, message="OK")
def validate_licenses(self) -> ValidationResult:
return ValidationResult(check_type=CheckType.LICENSES, passed=True, message="OK")
@pytest.fixture
def validator(tmp_path: Path) -> ConcreteValidator:
"""Create a test validator with temporary paths."""
svn_path = tmp_path / "svn"
svn_path.mkdir()
repo_root = tmp_path / "repo"
repo_root.mkdir()
return ConcreteValidator(
version="1.0.0rc1",
svn_path=svn_path,
airflow_repo_root=repo_root,
update_svn=False,
verbose=False,
)
class TestStripRcSuffix:
"""Tests for _strip_rc_suffix method."""
def test_strips_rc1_suffix(self, validator: ConcreteValidator):
assert validator._strip_rc_suffix("3.1.6rc1") == "3.1.6"
def test_strips_rc2_suffix(self, validator: ConcreteValidator):
assert validator._strip_rc_suffix("2.8.0rc2") == "2.8.0"
def test_strips_high_rc_number(self, validator: ConcreteValidator):
assert validator._strip_rc_suffix("1.0.0rc99") == "1.0.0"
def test_no_rc_suffix_unchanged(self, validator: ConcreteValidator):
assert validator._strip_rc_suffix("3.1.6") == "3.1.6"
def test_empty_string(self, validator: ConcreteValidator):
assert validator._strip_rc_suffix("") == ""
class TestCheckSvnLocks:
"""Tests for _check_svn_locks method."""
@patch("airflow_breeze.utils.release_validator.run_command")
def test_detects_lock_in_column_3(self, mock_run_command: MagicMock, validator: ConcreteValidator):
"""Should return True when SVN status shows 'L' in column 3 (lock indicator)."""
# SVN status format: columns [item][props][lock] - L at index 2
# Example: "! L path" - item=!, props=space, lock=L
mock_run_command.return_value = MagicMock(
returncode=0,
stdout=" L some/locked/path\n",
stderr="",
)
result = validator._check_svn_locks(validator.svn_path)
assert result is True
mock_run_command.assert_called_once()
@patch("airflow_breeze.utils.release_validator.run_command")
def test_detects_lock_with_other_status(self, mock_run_command: MagicMock, validator: ConcreteValidator):
"""Should return True when lock indicator appears with other status flags."""
# Format: [item][props][lock] - 'L' at index 2
# Example: "! L path" - missing item with lock
mock_run_command.return_value = MagicMock(
returncode=0,
stdout="! L modified/and/locked/file\n",
stderr="",
)
result = validator._check_svn_locks(validator.svn_path)
assert result is True
@patch("airflow_breeze.utils.release_validator.run_command")
def test_detects_E155037_error(self, mock_run_command: MagicMock, validator: ConcreteValidator):
"""Should return True when SVN returns E155037 lock error in stderr."""
mock_run_command.return_value = MagicMock(
returncode=1,
stdout="",
stderr="svn: E155037: Previous operation has not finished; run 'cleanup' if it was interrupted",
)
result = validator._check_svn_locks(validator.svn_path)
assert result is True
@patch("airflow_breeze.utils.release_validator.run_command")
def test_no_lock_returns_false(self, mock_run_command: MagicMock, validator: ConcreteValidator):
"""Should return False when no locks are detected."""
mock_run_command.return_value = MagicMock(
returncode=0,
stdout="M modified/file\nA added/file\n",
stderr="",
)
result = validator._check_svn_locks(validator.svn_path)
assert result is False
@patch("airflow_breeze.utils.release_validator.run_command")
def test_empty_output_returns_false(self, mock_run_command: MagicMock, validator: ConcreteValidator):
"""Should return False when SVN status output is empty (clean working copy)."""
mock_run_command.return_value = MagicMock(
returncode=0,
stdout="",
stderr="",
)
result = validator._check_svn_locks(validator.svn_path)
assert result is False
@patch("airflow_breeze.utils.release_validator.run_command")
def test_short_lines_handled(self, mock_run_command: MagicMock, validator: ConcreteValidator):
"""Should handle lines shorter than 3 characters without crashing."""
mock_run_command.return_value = MagicMock(
returncode=0,
stdout="M\nAB\n", # Lines shorter than 3 chars
stderr="",
)
result = validator._check_svn_locks(validator.svn_path)
assert result is False
class TestUpdateSvn:
"""Tests for _update_svn method."""
@patch("airflow_breeze.utils.release_validator.run_command")
@patch("airflow_breeze.utils.release_validator.console_print")
def test_successful_update(
self, mock_print: MagicMock, mock_run_command: MagicMock, validator: ConcreteValidator, tmp_path: Path
):
"""Should return True on successful SVN update."""
# First call: _check_svn_locks (no lock)
# Second call: svn update (success)
mock_run_command.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""), # svn status (no locks)
MagicMock(returncode=0, stdout="", stderr=""), # svn update (success)
]
# Create the expected directory structure
svn_dir = validator.get_svn_directory()
svn_dir.mkdir(parents=True)
result = validator._update_svn()
assert result is True
assert mock_run_command.call_count == 2
@patch("airflow_breeze.utils.release_validator.run_command")
@patch("airflow_breeze.utils.release_validator.console_print")
def test_fails_when_locked(
self, mock_print: MagicMock, mock_run_command: MagicMock, validator: ConcreteValidator
):
"""Should return False and print hint when working copy is locked."""
# SVN status with lock indicator at column 3 (index 2)
mock_run_command.return_value = MagicMock(
returncode=0,
stdout="! L locked/path\n",
stderr="",
)
result = validator._update_svn()
assert result is False
# Verify hint was printed
hint_printed = any("svn cleanup" in str(call) for call in mock_print.call_args_list)
assert hint_printed
@patch("airflow_breeze.utils.release_validator.run_command")
@patch("airflow_breeze.utils.release_validator.console_print")
def test_fails_on_update_error(
self, mock_print: MagicMock, mock_run_command: MagicMock, validator: ConcreteValidator
):
"""Should return False when svn update fails."""
mock_run_command.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""), # svn status (no locks)
MagicMock(returncode=1, stdout="", stderr="svn: E123456: Some error"), # svn update fails
]
result = validator._update_svn()
assert result is False
class TestValidateSignatures:
"""Tests for validate_signatures method."""
@patch("airflow_breeze.utils.release_validator.run_command")
@patch("airflow_breeze.utils.release_validator.console_print")
def test_all_signatures_valid(
self, mock_print: MagicMock, mock_run_command: MagicMock, validator: ConcreteValidator
):
"""Should pass when all signatures are valid."""
# Create test .asc files
svn_dir = validator.get_svn_directory()
svn_dir.mkdir(parents=True)
(svn_dir / "test-1.0.tar.gz").write_text("content")
(svn_dir / "test-1.0.tar.gz.asc").write_text("signature")
mock_run_command.return_value = MagicMock(
returncode=0,
stderr='Good signature from "Test User <test@example.com>"',
)
result = validator.validate_signatures()
assert result.passed is True
assert result.check_type == CheckType.SIGNATURES
assert "1 signatures verified" in result.message
@patch("airflow_breeze.utils.release_validator.run_command")
@patch("airflow_breeze.utils.release_validator.console_print")
def test_signature_verification_fails(
self, mock_print: MagicMock, mock_run_command: MagicMock, validator: ConcreteValidator
):
"""Should fail when signature verification fails."""
svn_dir = validator.get_svn_directory()
svn_dir.mkdir(parents=True)
(svn_dir / "test-1.0.tar.gz.asc").write_text("bad signature")
mock_run_command.return_value = MagicMock(
returncode=1,
stderr="gpg: BAD signature",
)
result = validator.validate_signatures()
assert result.passed is False
assert "1 of 1 signatures failed" in result.message
@patch("airflow_breeze.utils.release_validator.console_print")
def test_no_asc_files_found(self, mock_print: MagicMock, validator: ConcreteValidator):
"""Should fail when no .asc files are found."""
svn_dir = validator.get_svn_directory()
svn_dir.mkdir(parents=True)
# No .asc files created
result = validator.validate_signatures()
assert result.passed is False
assert "No .asc files found" in result.message
class TestValidateChecksums:
"""Tests for validate_checksums method."""
@patch("airflow_breeze.utils.release_validator.run_command")
@patch("airflow_breeze.utils.release_validator.console_print")
def test_all_checksums_valid(
self, mock_print: MagicMock, mock_run_command: MagicMock, validator: ConcreteValidator
):
"""Should pass when all checksums match."""
svn_dir = validator.get_svn_directory()
svn_dir.mkdir(parents=True)
# Create test file and compute its real SHA512
test_content = b"test content for checksum"
expected_hash = hashlib.sha512(test_content).hexdigest()
(svn_dir / "test-1.0.tar.gz").write_bytes(test_content)
(svn_dir / "test-1.0.tar.gz.sha512").write_text(f"{expected_hash} test-1.0.tar.gz\n")
mock_run_command.return_value = MagicMock(
returncode=0,
stdout=f"{expected_hash} test-1.0.tar.gz\n",
)
result = validator.validate_checksums()
assert result.passed is True
assert result.check_type == CheckType.CHECKSUMS
assert "1 checksums valid" in result.message
@patch("airflow_breeze.utils.release_validator.run_command")
@patch("airflow_breeze.utils.release_validator.console_print")
def test_checksum_mismatch(
self, mock_print: MagicMock, mock_run_command: MagicMock, validator: ConcreteValidator
):
"""Should fail when checksum doesn't match."""
svn_dir = validator.get_svn_directory()
svn_dir.mkdir(parents=True)
(svn_dir / "test-1.0.tar.gz").write_text("content")
(svn_dir / "test-1.0.tar.gz.sha512").write_text("expected_hash_value test-1.0.tar.gz\n")
mock_run_command.return_value = MagicMock(
returncode=0,
stdout="different_hash_value test-1.0.tar.gz\n",
)
result = validator.validate_checksums()
assert result.passed is False
assert "1 of 1 checksums failed" in result.message
@patch("airflow_breeze.utils.release_validator.console_print")
def test_target_file_missing(self, mock_print: MagicMock, validator: ConcreteValidator):
"""Should fail when target file for checksum is missing."""
svn_dir = validator.get_svn_directory()
svn_dir.mkdir(parents=True)
# Create .sha512 file but not the target file
(svn_dir / "test-1.0.tar.gz.sha512").write_text("somehash test-1.0.tar.gz\n")
result = validator.validate_checksums()
assert result.passed is False
assert result.details is not None
assert any("target file missing" in d for d in result.details)
@patch("airflow_breeze.utils.release_validator.console_print")
def test_no_sha512_files_found(self, mock_print: MagicMock, validator: ConcreteValidator):
"""Should fail when no .sha512 files are found."""
svn_dir = validator.get_svn_directory()
svn_dir.mkdir(parents=True)
result = validator.validate_checksums()
assert result.passed is False
assert "No .sha512 files found" in result.message
class TestValidatePrerequisites:
"""Tests for validate_prerequisites method."""
@patch("airflow_breeze.utils.release_validator.shutil.which")
@patch("airflow_breeze.utils.release_validator.run_command")
@patch("airflow_breeze.utils.release_validator.console_print")
def test_all_prerequisites_met(
self,
mock_print: MagicMock,
mock_run_command: MagicMock,
mock_which: MagicMock,
validator: ConcreteValidator,
):
"""Should return True when all prerequisites are available."""
# All tools available
mock_which.return_value = "/usr/bin/tool"
# Mock run_command for different calls
def run_command_side_effect(cmd, **kwargs):
result = MagicMock(returncode=0)
# git status --porcelain should return empty for clean directory
if "git" in cmd and "status" in cmd and "--porcelain" in cmd:
result.stdout = ""
else:
result.stdout = ""
return result
mock_run_command.side_effect = run_command_side_effect
# Create SVN directory with files
svn_dir = validator.get_svn_directory()
svn_dir.mkdir(parents=True)
(svn_dir / "test-1.0.tar.gz").write_text("content")
result = validator.validate_prerequisites()
assert result is True
@patch("airflow_breeze.utils.release_validator.shutil.which")
@patch("airflow_breeze.utils.release_validator.console_print")
def test_missing_java(self, mock_print: MagicMock, mock_which: MagicMock, validator: ConcreteValidator):
"""Should fail when Java is not installed."""
def which_side_effect(tool):
if tool == "java":
return None
return "/usr/bin/tool"
mock_which.side_effect = which_side_effect
result = validator.validate_prerequisites()
assert result is False
@patch("airflow_breeze.utils.release_validator.shutil.which")
@patch("airflow_breeze.utils.release_validator.run_command")
@patch("airflow_breeze.utils.release_validator.console_print")
def test_docker_not_running(
self,
mock_print: MagicMock,
mock_run_command: MagicMock,
mock_which: MagicMock,
validator: ConcreteValidator,
):
"""Should fail when Docker daemon is not running."""
mock_which.return_value = "/usr/bin/tool"
mock_run_command.return_value = MagicMock(returncode=1) # docker info fails
result = validator.validate_prerequisites()
assert result is False
@patch("airflow_breeze.utils.release_validator.shutil.which")
@patch("airflow_breeze.utils.release_validator.run_command")
@patch("airflow_breeze.utils.release_validator.console_print")
def test_signatures_check_only_requires_gpg_and_svn(
self,
mock_print: MagicMock,
mock_run_command: MagicMock,
mock_which: MagicMock,
validator: ConcreteValidator,
):
"""Should pass when only running signatures check without Java/Docker/hatch."""
def which_side_effect(tool):
# Only gpg and svn are available
if tool in ("gpg", "svn"):
return f"/usr/bin/{tool}"
return None # java, docker, hatch not available
mock_which.side_effect = which_side_effect
mock_run_command.return_value = MagicMock(returncode=0)
# Create SVN directory with files
svn_dir = validator.get_svn_directory()
svn_dir.mkdir(parents=True)
(svn_dir / "test-1.0.tar.gz").write_text("content")
# Should pass with only signatures check (requires gpg + svn)
result = validator.validate_prerequisites(checks=[CheckType.SIGNATURES])
assert result is True
@patch("airflow_breeze.utils.release_validator.shutil.which")
@patch("airflow_breeze.utils.release_validator.run_command")
@patch("airflow_breeze.utils.release_validator.console_print")
def test_checksums_check_only_requires_svn(
self,
mock_print: MagicMock,
mock_run_command: MagicMock,
mock_which: MagicMock,
validator: ConcreteValidator,
):
"""Should pass when only running checksums check without Java/Docker/hatch/GPG."""
def which_side_effect(tool):
# Only svn is available
if tool == "svn":
return "/usr/bin/svn"
return None # java, docker, hatch, gpg not available
mock_which.side_effect = which_side_effect
mock_run_command.return_value = MagicMock(returncode=0)
# Create SVN directory with files
svn_dir = validator.get_svn_directory()
svn_dir.mkdir(parents=True)
(svn_dir / "test-1.0.tar.gz").write_text("content")
# Should pass with only checksums check (requires only svn)
result = validator.validate_prerequisites(checks=[CheckType.CHECKSUMS])
assert result is True
@patch("airflow_breeze.utils.release_validator.shutil.which")
@patch("airflow_breeze.utils.release_validator.run_command")
@patch("airflow_breeze.utils.release_validator.console_print")
def test_reproducible_build_requires_docker_and_hatch(
self,
mock_print: MagicMock,
mock_run_command: MagicMock,
mock_which: MagicMock,
validator: ConcreteValidator,
):
"""Should fail when reproducible build check runs without Docker."""
def which_side_effect(tool):
# Docker not available
if tool == "docker":
return None
return f"/usr/bin/{tool}"
mock_which.side_effect = which_side_effect
mock_run_command.return_value = MagicMock(returncode=0)
# Should fail for reproducible-build check (requires docker)
result = validator.validate_prerequisites(checks=[CheckType.REPRODUCIBLE_BUILD])
assert result is False
class TestValidationResult:
"""Tests for ValidationResult dataclass."""
def test_create_passed_result(self):
result = ValidationResult(
check_type=CheckType.SVN,
passed=True,
message="All files present",
)
assert result.passed is True
assert result.check_type == CheckType.SVN
assert result.details is None
def test_create_failed_result_with_details(self):
result = ValidationResult(
check_type=CheckType.SIGNATURES,
passed=False,
message="Verification failed",
details=["file1.asc", "file2.asc"],
duration_seconds=1.5,
)
assert result.passed is False
assert len(result.details) == 2
assert result.duration_seconds == 1.5
class TestCheckType:
"""Tests for CheckType enum."""
def test_check_type_values(self):
assert CheckType.SVN.value == "svn"
assert CheckType.REPRODUCIBLE_BUILD.value == "reproducible-build"
assert CheckType.SIGNATURES.value == "signatures"
assert CheckType.CHECKSUMS.value == "checksums"
assert CheckType.LICENSES.value == "licenses"
def test_check_type_from_string(self):
assert CheckType("svn") == CheckType.SVN
assert CheckType("signatures") == CheckType.SIGNATURES