diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 86a4f23..1925afd 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.1.20 +current_version = 1.1.21 commit = True tag = False diff --git a/.github/workflows/release-controller.yml b/.github/workflows/release-controller.yml index 2602e63..2c88bf8 100644 --- a/.github/workflows/release-controller.yml +++ b/.github/workflows/release-controller.yml @@ -117,7 +117,7 @@ jobs: run: | zip -r "${REPO_NAME}-${GITHUB_REF_NAME}.zip" \ {INSTALL,SECURITY,README,ICONS,CONTRIBUTING,CODE_OF_CONDUCT}.md \ - requirements.txt .github scripts *adm* smtp-relay elastalert \ + requirements.txt requirements.dev.txt .github scripts *adm* smtp-relay elastalert \ pyproject.toml env: REPO_NAME: ${{ env.REPO_NAME }} diff --git a/.pylintrc b/.pylintrc index 337f65b..70bb4d7 100644 --- a/.pylintrc +++ b/.pylintrc @@ -46,7 +46,7 @@ fail-under=8 #from-stdin= # Files or directories to be skipped. They should be base names, not paths. -ignore=venv,node_modules,scripts +ignore=venv,.venv,env,node_modules,scripts,.git,__pycache__,build,dist,.eggs,.mypy_cache,.pytest_cache,.ruff_cache,.tox,htmlcov,coverage,.next # Add files or directories matching the regular expressions patterns to the # ignore-list. The regex matches against paths and can be in Posix or Windows @@ -106,7 +106,6 @@ source-roots= # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. -suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. diff --git a/CHANGELOG.md b/CHANGELOG.md index 387ab21..24cf3bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,37 @@ -## [1.1.19] - 2026-03-03 +## [1.1.21] - 2026-06-15 + +### Bug Fixes + +- **core**: derive changelog versions from bump commits to close gaps (`patch candidate`) +- **core**: resolve key pair year rollover, formatter hook bugs and enforce 96 coverage gate +- **core**: handle existing tag gracefully in version-controller workflow +- **deps**: update setuptools requirement from ^82.0.0 to ^82.0.1 (#115) +- **deps**: update requests requirement from ^2.32.3 to ^2.34.2 (#114) +- **deps**: update bump2version requirement from ^1.0.0 to ^1.0.1 (#113) +- **deps**: update pytest requirement from ^8.3.1 to ^9.0.3 (#112) + +## [1.1.20] - 2026-05-30 ### Features -- **core**: add inline release creation to version controller (`patch candidate`) +- **core**: add validate container names script for cloudformation templates (`patch candidate`) + +### Bug Fixes + +- **core**: use docker compose v2 plugin instead of deprecated docker-compose v1 +- **core**: make fetch tags non-fatal in generate changelog hook + +## [1.1.19] - 2026-03-03 ### Bug Fixes - **core**: add pull-requests write permission to version controller (`patch candidate`) -### Documentation +## [1.1.18] - 2026-03-03 -- **core**: update changelog +### Features + +- **core**: add inline release creation to version controller (`patch candidate`) ## [1.1.17] - 2026-03-03 @@ -18,21 +39,13 @@ - **core**: use access token for tag push to trigger release workflow (`patch candidate`) -### Documentation - -- **core**: update changelog - ## [1.1.16] - 2026-03-03 ### Features - **core**: add inline release notes and update cicd workflows (`patch candidate`) -### Documentation - -- **core**: update changelog - -## [1.1.15] - 2026-03-02 +## [1.1.15] - 2026-03-03 ### Bug Fixes @@ -88,6 +101,8 @@ - **core**: bump year version (`patch candidate`) +## [1.1.11] - 2025-01-07 + ### Chores - **core**: bump year version (`patch candidate`) @@ -107,6 +122,9 @@ - deps: update pytest-cov requirement from ^5.0.0 to ^6.0.0 (#45) - deps: update pytest-cov requirement from ^5.0.0 to ^6.0.0 +- Update CNAME +- Update CNAME +- Create CNAME ## [1.1.9] - 2024-10-29 @@ -151,20 +169,37 @@ ## [1.1.2] - 2024-10-27 -### Features +### Bug Fixes - **core**: fix commit icons and commit regex validation (`patch candidate`) -- **core**: added commit icons and commit regex validation (`minor candidate`) -### Bug Fixes +## [1.1.1] - 2024-10-27 + +### Features - **core**: fix commit icons and commit regex validation (`patch candidate`) +## [1.1.0] - 2024-10-27 + +### Features + +- **core**: added commit icons and commit regex validation (`minor candidate`) + ### Chores - **core**: test icons - **core**: test icons + +## [1.0.19] - 2024-10-27 + +### Chores + - **core**: test icons (`patch candidate`) + +## [1.0.18] - 2024-10-27 + +### Chores + - **core**: test icons (`patch candidate`) - **core**: test icons - **core**: test icons flow @@ -174,6 +209,11 @@ ### Chores - **core**: test icons flow (`patch candidate`) + +## [1.0.14] - 2024-10-27 + +### Chores + - **core**: test version controller (`patch candidate`) ## [1.0.13] - 2024-10-27 @@ -204,6 +244,11 @@ ### Chores - **style**: fixed bump year flow (`patch candidate`) + +## [1.0.9] - 2024-10-26 + +### Chores + - **style**: fixed bump year flow (`patch candidate`) - **core**: added crypto controller @@ -213,19 +258,28 @@ - **core**: fixed confs (`patch candidate`) -### Other Changes - -- Update CNAME -- Update CNAME -- Create CNAME - ## [1.0.7] - 2024-10-23 ### Styles - **core**: fixed confs (`patch candidate`) + +## [1.0.6] - 2024-10-23 + +### Styles + - **core**: fixed confs (`patch candidate`) + +## [1.0.5] - 2024-10-23 + +### Styles + - **core**: fixed confs (`patch candidate`) + +## [1.0.4] - 2024-10-23 + +### Styles + - **core**: fixed confs (`patch candidate`) ## [1.0.3] - 2024-10-23 @@ -233,6 +287,11 @@ ### Styles - **core**: added readme file (`patch candidate`) + +## [1.0.2] - 2024-10-23 + +### Styles + - **core**: added readme file (`patch candidate`) - **core**: added readme file diff --git a/README.md b/README.md index fc55686..6af2a71 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Setting up a Python virtual environment ensures that dependencies are managed ef 4. **Install Dependencies** ```bash - pip install -r requirements.txt + pip install -r requirements.txt -r requirements.dev.txt pip install poetry poetry lock poetry install diff --git a/commit_msg_version_bump/main.py b/commit_msg_version_bump/main.py index 03721d9..4f26f6c 100644 --- a/commit_msg_version_bump/main.py +++ b/commit_msg_version_bump/main.py @@ -233,26 +233,32 @@ def amend_commit(new_commit_msg: str) -> None: """ Amends the current commit with the new commit message. + Pre-commit hooks (e.g. generate-changelog, prettier) may modify tracked files such + as CHANGELOG.md while the amend runs, which aborts the amend. We re-stage those + tracked changes with ``git add -u`` and retry so they are folded into the bump + commit. ``--no-verify``/``--force`` are never used (global rule): the hooks are + idempotent, so the retry converges once their changes are staged. + Args: new_commit_msg (str): The new commit message. - - Raises: - subprocess.CalledProcessError: If git amend fails. """ - try: - # Amend the commit with the new commit message - subprocess.run( - ["git", "commit", "--amend", "-m", new_commit_msg], - check=True, - encoding="utf-8", - ) - logger.info("Successfully amended the commit with the new version bump.") - logger.info( - "Please perform a push using 'git push' to update the remote repository. Avoid using --force" + amend_cmd = ["git", "commit", "--amend", "-m", new_commit_msg] + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + result = subprocess.run(amend_cmd, encoding="utf-8") + if result.returncode == 0: + logger.info("Successfully amended the commit with the new version bump.") + logger.info( + "Please perform a push using 'git push' to update the remote repository. Avoid using --force" + ) + return + logger.warning( + f"Amend attempt {attempt} aborted by pre-commit hooks modifying files; " + "re-staging tracked changes and retrying." ) - except subprocess.CalledProcessError as e: - logger.error(f"Failed to amend the commit: {e}") - sys.exit(1) + subprocess.run(["git", "add", "-u"], check=True, encoding="utf-8") + logger.error(f"Failed to amend the commit after {max_attempts} attempts.") + sys.exit(1) def main() -> None: diff --git a/crypto_controller/main.py b/crypto_controller/main.py index c0ff3f4..06dd951 100644 --- a/crypto_controller/main.py +++ b/crypto_controller/main.py @@ -13,7 +13,8 @@ import warnings import smtplib from email.mime.text import MIMEText -import json # Added import for JSON handling +import json +import re from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa, padding @@ -536,6 +537,19 @@ def get_status(self) -> None: print("Failed to retrieve status. Check logs for more details.") +def _resolve_key_pair_name(cert_location: str) -> str: + """Returns the active key pair name whose year range contains the current year.""" + current_year = datetime.now().year + if os.path.isdir(cert_location): + for fname in os.listdir(cert_location): + match = re.match(r"^Crypto-Key-Pair-(\d{4})-(\d{4})\.kp$", fname) + if match: + start, end = int(match.group(1)), int(match.group(2)) + if start <= current_year <= end: + return fname[:-3] + return f"Crypto-Key-Pair-{current_year}-{current_year + int(CERT_EXPIRATION_YEARS)}" + + def parse_arguments() -> argparse.Namespace: """ Parses command-line arguments. @@ -561,7 +575,7 @@ def parse_arguments() -> argparse.Namespace: ) parser.add_argument( "--key-pair-name", - default=f"Crypto-Key-Pair-{datetime.now().year}", + default=_resolve_key_pair_name(os.path.join(os.getcwd(), "certs")), help="Name of the key pair. Defaults to 'Crypto-Key-Pair-'.", ) parser.add_argument( diff --git a/crypto_controller/requirements.txt b/crypto_controller/requirements.txt index 7c6a8fe..15d05e9 100644 --- a/crypto_controller/requirements.txt +++ b/crypto_controller/requirements.txt @@ -4,3 +4,4 @@ python-dotenv>=0.19.0 wheel>=0.36.2 pytest>=7.0.0 pytest-cov>=4.0.0 +pytest-mock>=3.11.0 diff --git a/format_yaml/main.py b/format_yaml/main.py index 5860645..bbfbedd 100644 --- a/format_yaml/main.py +++ b/format_yaml/main.py @@ -61,13 +61,12 @@ def format_yaml_file(file_path): new_lines.append(line) - formatted_content = "\n".join(lines) + "\n" + formatted_content = "\n".join(new_lines) + "\n" with open(file_path, "w", newline="\n", encoding="utf-8") as f: f.write(formatted_content) print(f"[FORMAT] Formatted {file_path} with LF line endings.") - sys.exit(0) def main(): diff --git a/format_yml/main.py b/format_yml/main.py index 417d169..4dc14af 100644 --- a/format_yml/main.py +++ b/format_yml/main.py @@ -38,20 +38,16 @@ def format_yml_file(file_path): while len(line) > 120: split_pos = line.rfind(" ", 0, 120) - if split_pos != -1: + if split_pos > current_indent: split_line1 = line[:split_pos] + " \\" - split_line2 = " " * current_indent + " " + line[split_pos + 1 :].lstrip() + line = " " * current_indent + " " + line[split_pos + 1 :].lstrip() new_lines.append(split_line1) - new_lines.append(split_line2) print(f"[FORMAT] Split long line at line {i} in {file_path}.") - continue - if not split_pos != -1: + else: split_line1 = line[:120] + " \\" - split_line2 = " " * current_indent + " " + line[120 + 1 :].lstrip() + line = " " * current_indent + " " + line[120:].lstrip() new_lines.append(split_line1) - new_lines.append(split_line2) print(f"[FORMAT] Force split long line at line {i} in {file_path}.") - continue new_lines.append(line) @@ -61,7 +57,6 @@ def format_yml_file(file_path): f.write(formatted_content) print(f"[FORMAT] Formatted {file_path} with LF line endings.") - sys.exit(0) def main(): diff --git a/generate_changelog/main.py b/generate_changelog/main.py index 9923b84..43300f3 100644 --- a/generate_changelog/main.py +++ b/generate_changelog/main.py @@ -43,6 +43,15 @@ re.compile(r"from \w+ - from \w+", re.IGNORECASE), ] +# Matches a bump2version commit "Bump version: " (robust to a leading 'v' +# and to pre-release suffixes on either side). CI promotion commits +# ("From → Bump version: vX.Y.Z- into ") use "into" instead of +# the arrow, so they are NOT matched here. +BUMP_VERSION_REGEX = re.compile( + r"Bump version:\s*v?\d+\.\d+\.\d+\S*\s*(?:→|->)\s*(?Pv?\d+\.\d+\.\d+)", + re.IGNORECASE, +) + # Mapping of commit types to changelog sections TYPE_MAPPING = { "feat": "### Features", @@ -176,6 +185,88 @@ def compare_versions(v1: str, v2: str) -> int: return 0 +def normalize_version(version_str: str) -> str: + """ + Normalizes a version string to bare 'X.Y.Z', tolerating an optional leading 'v' + and any pre-release suffix (e.g. 'v1.2.8-development' -> '1.2.8', '1.2.8' -> '1.2.8'). + + Args: + version_str (str): The raw version string. + + Returns: + str: The normalized 'X.Y.Z' string. + """ + s = version_str.strip() + if s[:1].lower() == "v": + s = s[1:] + return s.split("-")[0] + + +def get_version_history() -> List[Dict[str, str]]: + """ + Derives the released version sequence from 'Bump version: X -> Y' commits, + ordered oldest to newest. This is independent of tags, so every bumped version is + documented even if it never received a release tag (closing changelog gaps). + + Returns: + List[Dict[str, str]]: List of {"version": 'X.Y.Z', "commit": , "date": 'YYYY-MM-DD'}. + """ + try: + output = subprocess.check_output( + ["git", "log", "--reverse", "--pretty=format:%H\x1f%ai\x1f%s"], + encoding="utf-8", + ) + except subprocess.CalledProcessError as error: + logger.error(f"Error retrieving commit history: {error}") + return [] + + history: List[Dict[str, str]] = [] + seen = set() + for line in output.split("\n"): + if not line.strip(): + continue + parts = line.split("\x1f") + if len(parts) != 3: + continue + commit_hash, date_str, subject = parts + match = BUMP_VERSION_REGEX.search(subject) + if not match: + continue + version = normalize_version(match.group("new")) + if version in seen: + continue + seen.add(version) + history.append({"version": version, "commit": commit_hash, "date": date_str.split(" ")[0]}) + + # Sort by semantic version so the changelog stays consistent even if history was rebased. + history.sort(key=lambda entry: parse_version(entry["version"])) + logger.debug(f"Derived {len(history)} versions from bump commits.") + return history + + +def build_from_history( + history: List[Dict[str, str]], +) -> Tuple[Dict[str, List[str]], Dict[str, str]]: + """ + Builds the per-version commit map and dates from the bump-version history. + Commits in (previous_bump, current_bump] belong to the current version. + + Args: + history (List[Dict[str, str]]): Output of get_version_history() (ascending). + + Returns: + Tuple[Dict[str, List[str]], Dict[str, str]]: (commits_by_version, dates_by_version). + """ + commits_dict: Dict[str, List[str]] = OrderedDict() + dates: Dict[str, str] = {} + previous_commit = "" + for entry in history: + commits_dict[entry["version"]] = get_commits_between_tags(previous_commit, entry["commit"]) + dates[entry["version"]] = entry["date"] + previous_commit = entry["commit"] + return commits_dict, dates + + def is_noise_commit(commit: str) -> bool: """ Checks if a commit message is noise that should be filtered out. @@ -480,6 +571,10 @@ def generate_full_changelog(commits_dict: Dict[str, List[str]], tag_dates: Dict[ if not commits: continue changelog, non_conforming = parse_commits(commits) + # Skip versions whose range is entirely noise (bumps/merges) to avoid empty headers. + if not changelog and not non_conforming: + logger.debug(f"Skipping version {version} with no conforming commits.") + continue date = tag_dates.get(version, datetime.now(timezone.utc).strftime("%Y-%m-%d")) changelog_entry = generate_changelog_entry(version, changelog, non_conforming, date) changelog_content += changelog_entry @@ -536,9 +631,19 @@ def main() -> None: Main function to generate or update the CHANGELOG.md. """ fetch_tags() - sorted_tags = get_sorted_tags() - tag_dates = get_tag_dates(sorted_tags) - commits_dict = get_all_commits(sorted_tags) + + # Primary: derive every version from 'Bump version: X -> Y' commits so no bumped + # version is skipped (tag-independent). Fallback: legacy tag-based generation for + # repositories that do not use bump-version commits. + history = get_version_history() + if history: + commits_dict, tag_dates = build_from_history(history) + else: + logger.info("No bump-version commits found; falling back to tag-based changelog.") + sorted_tags = get_sorted_tags() + tag_dates = get_tag_dates(sorted_tags) + commits_dict = get_all_commits(sorted_tags) + changelog_content = generate_full_changelog(commits_dict, tag_dates) if not changelog_content: diff --git a/pyproject.toml b/pyproject.toml index badc6d0..13755c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "scripts" -version = "1.1.20" +version = "1.1.21" description = "CICD Core Scripts" authors = ["B "] license = "GLPv3" @@ -10,13 +10,13 @@ package-mode = false [tool.poetry.dependencies] python = "^3.12" setuptools = "^82.0.1" -idna = "^3.0" -certifi = "^2026.1.4" +idna = "^3.18" +certifi = "^2026.5.20" bump2version = "^1.0.1" python-dotenv = "^1.0.0" cryptography = "^46.0.5" requests = "^2.34.2" -wheel = ">=0.36.2" +wheel = ">=0.47.0" [tool.poetry.group.dev.dependencies] pre-commit = "^4.0.0" @@ -26,7 +26,7 @@ isort = "^6.0.1" toml = "^0.10.0" black = "^26.1.0" pytest = "^9.0.3" -httpx = { version = ">=0.24.0", optional = true } +httpx = { version = ">=0.28.1", optional = true } pytest-cov = "^6.0.0" coverage = "^7.2.5" @@ -56,12 +56,36 @@ exclude = ''' [tool.pytest.ini_options] minversion = "6.0" -addopts = "--cov=app --cov-report=xml:coverage.xml --cov-report=term" +addopts = "--cov --cov-report=xml:coverage.xml --cov-report=term" testpaths = ["tests"] pythonpath = [ ".", "crypto_controller" ] +[tool.coverage.run] +source = [ + "bump_year", + "commit_msg_version_bump", + "control_commit", + "crypto_controller", + "format_yaml", + "format_yml", + "generate_changelog", + "init_security_config", + "init_template", + "validate_container_names", + "validate_docker_compose", +] + +[tool.coverage.report] +fail_under = 96 +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "raise EnvironmentError", + "except KeyError as e_key_error_fetch_password:", +] + [tool.isort] profile = "black" line_length = 100 diff --git a/requirements.dev.txt b/requirements.dev.txt new file mode 100644 index 0000000..aa64a72 --- /dev/null +++ b/requirements.dev.txt @@ -0,0 +1,25 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Development / CI dependencies — mirrors [tool.poetry.group.dev.dependencies]. +# Runtime dependencies live in requirements.txt. +# ───────────────────────────────────────────────────────────────────────────── + +# Pre-Commit for hook management +pre-commit>=4.0.0 + +# Linters and formatters +pylint>=4.0.5 +yamllint>=1.35.0 +isort>=6.0.1 +black>=26.1.0 + +# Toml for pyproject.toml management +toml>=0.10.0 + +# Poetry for package management +poetry>=2.4.1 + +# Testing +pytest>=9.0.3 +pytest-cov>=6.0.0 +coverage>=7.2.5 +httpx>=0.28.1 diff --git a/requirements.txt b/requirements.txt index 2c38fac..61ae1d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,33 +1,22 @@ -# Pre-Commit for pre-commit hooks management -pre-commit>=4.0.0 +# ───────────────────────────────────────────────────────────────────────────── +# Runtime dependencies — mirrors [tool.poetry.dependencies] in pyproject.toml. +# Development / CI tooling lives in requirements.dev.txt. +# ───────────────────────────────────────────────────────────────────────────── -# Pylint for python lint -pylint>=3.3.1 - -# YamlLint for YAML lint -yamllint>=1.35.1 - -# Poetry for package management alternative -poetry>=1.8.4 +# Setuptools / Wheel for builds +setuptools>=82.0.1 +wheel>=0.47.0 # Bump2Version for version release control -bump2version>=1.0.0 - -# Toml for pyproject.toml management -toml>=0.10.1 - -# Black for Code Formatting -black>=24.3.0 - -# Pytest for Unit/Integration Testing -pytest>=7.3.1 -pytest-cov>=5.0.0 - -# Coverage Reporting -coverage>=7.2.5 - -requests~=2.32.4 +bump2version>=1.0.1 +# Python-dotenv for environment management python-dotenv>=1.0.0 -cryptography>=44.0.0 +# Requests + certifi + idna for HTTP and TLS +requests>=2.34.2 +certifi>=2026.5.20 +idna>=3.18 + +# Cryptography for key management / encryption +cryptography>=46.0.5 diff --git a/tests/test_bump_year.py b/tests/test_bump_year.py new file mode 100644 index 0000000..775d013 --- /dev/null +++ b/tests/test_bump_year.py @@ -0,0 +1,185 @@ +# tests/test_bump_year.py + +import argparse +import datetime +import logging +import sys + +import pytest + +import bump_year.main as bump_year_main +from bump_year.main import collect_markdown_files, configure_logger, update_markdown_footers + +CURRENT_YEAR = datetime.datetime.now().year + + +def test_parse_arguments_defaults(monkeypatch): + """parse_arguments returns the default markdown files and INFO level.""" + monkeypatch.setattr(sys, "argv", ["bump_year"]) + args = bump_year_main.parse_arguments() + assert "README.md" in args.md_files + assert "LICENSE" in args.md_files + assert args.md_dir is None + assert args.log_level == "INFO" + + +def test_parse_arguments_custom_values(monkeypatch): + """parse_arguments honors explicit --md-files, --md-dir and --log-level.""" + monkeypatch.setattr( + sys, + "argv", + ["bump_year", "--md-files", "A.md", "B.md", "--md-dir", "docs", "--log-level", "DEBUG"], + ) + args = bump_year_main.parse_arguments() + assert args.md_files == ["A.md", "B.md"] + assert args.md_dir == "docs" + assert args.log_level == "DEBUG" + + +def test_configure_logger_invalid_level_raises(tmp_path, monkeypatch): + """An unknown log level raises ValueError.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="Invalid log level"): + configure_logger("VERBOSE") + + +def test_configure_logger_sets_handlers(tmp_path, monkeypatch): + """A valid level configures file and console handlers.""" + monkeypatch.chdir(tmp_path) + configure_logger("DEBUG") + assert bump_year_main.logger.level == logging.DEBUG + assert len(bump_year_main.logger.handlers) == 2 + + +def test_update_footer_replaces_previous_year(tmp_path): + """The footer containing last year is bumped to the current year.""" + md_file = tmp_path / "README.md" + md_file.write_text(f"# Title\n\nSome text.\n\n© {CURRENT_YEAR - 1} Quipux\n", encoding="utf-8") + update_markdown_footers([str(md_file)], CURRENT_YEAR) + content = md_file.read_text(encoding="utf-8") + assert f"© {CURRENT_YEAR} Quipux" in content + assert str(CURRENT_YEAR - 1) not in content + + +def test_update_footer_only_touches_last_matching_line(tmp_path): + """Only the footer (last line with the previous year) is updated.""" + md_file = tmp_path / "README.md" + md_file.write_text( + f"Copyright {CURRENT_YEAR - 1} header\n\nbody\n\n© {CURRENT_YEAR - 1} Footer\n", + encoding="utf-8", + ) + update_markdown_footers([str(md_file)], CURRENT_YEAR) + lines = md_file.read_text(encoding="utf-8").splitlines() + assert lines[0] == f"Copyright {CURRENT_YEAR - 1} header" + assert lines[-1] == f"© {CURRENT_YEAR} Footer" + + +def test_update_footer_skips_file_already_at_current_year(tmp_path, caplog): + """A footer already at the current year is left untouched.""" + md_file = tmp_path / "README.md" + original = f"© {CURRENT_YEAR} Quipux\n" + md_file.write_text(original, encoding="utf-8") + with caplog.at_level(logging.WARNING, logger="bump_year.main"): + update_markdown_footers([str(md_file)], CURRENT_YEAR) + assert md_file.read_text(encoding="utf-8") == original + assert "No footer with a year to update" in caplog.text + + +def test_update_footer_skips_file_without_years(tmp_path): + """A file without any year is skipped.""" + md_file = tmp_path / "README.md" + original = "no dates here\n" + md_file.write_text(original, encoding="utf-8") + update_markdown_footers([str(md_file)], CURRENT_YEAR) + assert md_file.read_text(encoding="utf-8") == original + + +def test_update_footer_warns_on_missing_file(tmp_path, caplog): + """A nonexistent file logs a warning and is skipped.""" + with caplog.at_level(logging.WARNING, logger="bump_year.main"): + update_markdown_footers([str(tmp_path / "missing.md")], CURRENT_YEAR) + assert "does not exist. Skipping." in caplog.text + + +def test_update_footer_logs_read_errors(tmp_path, caplog): + """An unreadable file logs an error and does not raise.""" + md_file = tmp_path / "README.md" + md_file.write_text(f"© {CURRENT_YEAR - 1}\n", encoding="utf-8") + md_file.chmod(0o000) + try: + with caplog.at_level(logging.ERROR, logger="bump_year.main"): + update_markdown_footers([str(md_file)], CURRENT_YEAR) + finally: + md_file.chmod(0o644) + assert "Error reading" in caplog.text + + +def test_update_footer_logs_write_errors(tmp_path, caplog): + """A read-only file logs a write error and does not raise.""" + md_file = tmp_path / "README.md" + md_file.write_text(f"© {CURRENT_YEAR - 1}\n", encoding="utf-8") + md_file.chmod(0o444) + try: + with caplog.at_level(logging.ERROR, logger="bump_year.main"): + update_markdown_footers([str(md_file)], CURRENT_YEAR) + finally: + md_file.chmod(0o644) + assert "Error writing to" in caplog.text + + +def test_collect_markdown_files_without_directory(): + """Without md_dir the provided file list is returned as is.""" + collected = collect_markdown_files(["README.md", "CONTRIBUTING.md"]) + assert set(collected) == {"README.md", "CONTRIBUTING.md"} + + +def test_collect_markdown_files_walks_directory(tmp_path): + """md_dir is walked recursively collecting only markdown files.""" + (tmp_path / "x.md").write_text("x", encoding="utf-8") + (tmp_path / "UPPER.MD").write_text("u", encoding="utf-8") + (tmp_path / "z.txt").write_text("z", encoding="utf-8") + sub = tmp_path / "sub" + sub.mkdir() + (sub / "y.md").write_text("y", encoding="utf-8") + + collected = collect_markdown_files(["base.md"], str(tmp_path)) + assert "base.md" in collected + assert str(tmp_path / "x.md") in collected + assert str(tmp_path / "UPPER.MD") in collected + assert str(sub / "y.md") in collected + assert str(tmp_path / "z.txt") not in collected + + +def test_collect_markdown_files_warns_on_missing_directory(tmp_path, caplog): + """A nonexistent md_dir logs a warning and only files are returned.""" + with caplog.at_level(logging.WARNING, logger="bump_year.main"): + collected = collect_markdown_files(["base.md"], str(tmp_path / "missing")) + assert collected == ["base.md"] + assert "does not exist. Skipping." in caplog.text + + +def test_main_updates_collected_files(tmp_path, monkeypatch): + """main() bumps the footer of the files referenced by the module-level args.""" + md_file = tmp_path / "README.md" + md_file.write_text(f"© {CURRENT_YEAR - 1} Quipux\n", encoding="utf-8") + monkeypatch.setattr( + bump_year_main, + "args", + argparse.Namespace(md_files=[str(md_file)], md_dir=None), + raising=False, + ) + bump_year_main.main() + assert f"© {CURRENT_YEAR} Quipux" in md_file.read_text(encoding="utf-8") + + +def test_main_logs_error_when_no_files(monkeypatch, caplog): + """main() logs an error when no markdown files are collected.""" + monkeypatch.setattr( + bump_year_main, + "args", + argparse.Namespace(md_files=[], md_dir=None), + raising=False, + ) + with caplog.at_level(logging.ERROR, logger="bump_year.main"): + bump_year_main.main() + assert "No Markdown files specified" in caplog.text diff --git a/tests/test_commit_msg_version_bump.py b/tests/test_commit_msg_version_bump.py new file mode 100644 index 0000000..92638b9 --- /dev/null +++ b/tests/test_commit_msg_version_bump.py @@ -0,0 +1,236 @@ +# tests/test_commit_msg_version_bump.py + +import subprocess +import sys +from unittest import mock + +import pytest + +import commit_msg_version_bump.main as cmvb +from commit_msg_version_bump.main import ( + add_icon_and_prepare_commit_message, + amend_commit, + bump_version, + configure_logger, + determine_version_bump, + get_current_version, + get_latest_commit_message, + get_new_version, + parse_arguments, + stage_changes, +) + + +def _write_pyproject(tmp_path, version='version = "1.2.3"'): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(f"[tool.poetry]\nname = 'x'\n{version}\n", encoding="utf-8") + return str(pyproject) + + +@pytest.mark.parametrize( + ("message", "expected"), + [ + ("feat: add feature [minor candidate]", "minor"), + ("fix: solve bug [patch candidate]", "patch"), + ("refactor: rework core [major candidate]", "major"), + ("fix: solve bug [Patch Candidate]", "patch"), # case-insensitive + ("fix: [patch candidate] not at the end", None), + ("fix: solve bug", None), + ], +) +def test_determine_version_bump(message, expected): + """The candidate keyword is only honored at the end of the message.""" + assert determine_version_bump(message) == expected + + +def test_parse_arguments_defaults(monkeypatch): + """parse_arguments defaults to INFO log level.""" + monkeypatch.setattr(sys, "argv", ["commit_msg_version_bump"]) + assert parse_arguments().log_level == "INFO" + + +def test_configure_logger_invalid_level_raises(tmp_path, monkeypatch): + """An unknown log level raises ValueError.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="Invalid log level"): + configure_logger("LOUD") + + +def test_get_latest_commit_message(mocker): + """The latest commit subject is returned stripped.""" + mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + return_value=mock.Mock(stdout="✨ feat: add feature\n"), + ) + assert get_latest_commit_message() == "✨ feat: add feature" + + +def test_get_latest_commit_message_failure_exits_1(mocker): + """A git failure exits with code 1.""" + mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "git", stderr="boom"), + ) + with pytest.raises(SystemExit) as exc_info: + get_latest_commit_message() + assert exc_info.value.code == 1 + + +def test_get_current_version_reads_pyproject(tmp_path): + """The poetry version is read from pyproject.toml.""" + assert get_current_version(_write_pyproject(tmp_path)) == "1.2.3" + + +def test_get_current_version_missing_file_exits_1(tmp_path): + """A missing pyproject.toml exits with code 1.""" + with pytest.raises(SystemExit) as exc_info: + get_current_version(str(tmp_path / "missing.toml")) + assert exc_info.value.code == 1 + + +def test_get_current_version_invalid_toml_exits_1(tmp_path): + """An unparsable pyproject.toml exits with code 1.""" + bad = tmp_path / "pyproject.toml" + bad.write_text(":::: not toml ::::", encoding="utf-8") + with pytest.raises(SystemExit) as exc_info: + get_current_version(str(bad)) + assert exc_info.value.code == 1 + + +def test_get_current_version_missing_key_exits_1(tmp_path): + """A pyproject.toml without tool.poetry.version exits with code 1.""" + bad = tmp_path / "pyproject.toml" + bad.write_text("[tool.other]\nname = 'x'\n", encoding="utf-8") + with pytest.raises(SystemExit) as exc_info: + get_current_version(str(bad)) + assert exc_info.value.code == 1 + + +def test_get_new_version_reads_pyproject(tmp_path): + """The bumped version is read back from pyproject.toml.""" + assert get_new_version(_write_pyproject(tmp_path, 'version = "1.3.0"')) == "1.3.0" + + +def test_get_new_version_missing_file_exits_1(tmp_path): + """A missing pyproject.toml exits with code 1.""" + with pytest.raises(SystemExit) as exc_info: + get_new_version(str(tmp_path / "missing.toml")) + assert exc_info.value.code == 1 + + +def test_add_icon_and_prepare_commit_message(): + """The bump commit message carries the bookmark icon and both versions.""" + assert add_icon_and_prepare_commit_message("1.0.0", "1.1.0") == "🔖 Bump version: 1.0.0 → 1.1.0" + + +def test_bump_version_invokes_bump2version(mocker): + """bump2version is invoked with the requested part.""" + mock_run = mocker.patch("commit_msg_version_bump.main.subprocess.run") + bump_version("patch") + mock_run.assert_called_once_with(["bump2version", "patch"], check=True, encoding="utf-8") + + +def test_bump_version_failure_exits_1(mocker): + """A bump2version failure exits with code 1.""" + mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "bump2version"), + ) + with pytest.raises(SystemExit) as exc_info: + bump_version("patch") + assert exc_info.value.code == 1 + + +def test_stage_changes_invokes_git_add(mocker): + """pyproject.toml is staged for the amended commit.""" + mock_run = mocker.patch("commit_msg_version_bump.main.subprocess.run") + stage_changes() + mock_run.assert_called_once_with(["git", "add", "pyproject.toml"], check=True, encoding="utf-8") + + +def test_stage_changes_failure_exits_1(mocker): + """A git add failure exits with code 1.""" + mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + with pytest.raises(SystemExit) as exc_info: + stage_changes() + assert exc_info.value.code == 1 + + +def test_amend_commit_invokes_git(mocker): + """The commit is amended with the bump message.""" + mock_run = mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + return_value=subprocess.CompletedProcess(args=[], returncode=0), + ) + amend_commit("🔖 Bump version: 1.0.0 → 1.0.1") + mock_run.assert_called_once_with( + ["git", "commit", "--amend", "-m", "🔖 Bump version: 1.0.0 → 1.0.1"], + encoding="utf-8", + ) + + +def test_amend_commit_failure_exits_1(mocker): + """An amend that keeps being aborted by hooks exits with code 1 after retries.""" + mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + return_value=subprocess.CompletedProcess(args=[], returncode=1), + ) + with pytest.raises(SystemExit) as exc_info: + amend_commit("🔖 Bump version: 1.0.0 → 1.0.1") + assert exc_info.value.code == 1 + + +def test_amend_commit_restages_and_retries_when_hooks_modify_files(mocker): + """When pre-commit hooks modify files, the amend re-stages (git add -u) and retries.""" + mock_run = mocker.patch( + "commit_msg_version_bump.main.subprocess.run", + side_effect=[ + subprocess.CompletedProcess(args=[], returncode=1), # 1st amend aborted by hooks + subprocess.CompletedProcess(args=[], returncode=0), # git add -u + subprocess.CompletedProcess(args=[], returncode=0), # 2nd amend succeeds + ], + ) + amend_commit("🔖 Bump version: 1.0.0 → 1.0.1") + assert mock_run.call_count == 3 + assert any(call.args[0] == ["git", "add", "-u"] for call in mock_run.call_args_list) + + +def test_main_with_candidate_bumps_and_exits_1(tmp_path, monkeypatch, mocker): + """A candidate keyword triggers bump, stage and amend, then aborts the push.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["commit_msg_version_bump"]) + mocker.patch( + "commit_msg_version_bump.main.get_latest_commit_message", + return_value="fix: solve bug [patch candidate]", + ) + mocker.patch("commit_msg_version_bump.main.get_current_version", return_value="1.0.0") + mock_bump = mocker.patch("commit_msg_version_bump.main.bump_version") + mocker.patch("commit_msg_version_bump.main.get_new_version", return_value="1.0.1") + mock_stage = mocker.patch("commit_msg_version_bump.main.stage_changes") + mock_amend = mocker.patch("commit_msg_version_bump.main.amend_commit") + + with pytest.raises(SystemExit) as exc_info: + cmvb.main() + + assert exc_info.value.code == 1 + mock_bump.assert_called_once_with("patch") + mock_stage.assert_called_once() + mock_amend.assert_called_once_with("🔖 Bump version: 1.0.0 → 1.0.1") + + +def test_main_without_candidate_does_nothing(tmp_path, monkeypatch, mocker): + """Without a candidate keyword no bump happens and main returns normally.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["commit_msg_version_bump"]) + mocker.patch( + "commit_msg_version_bump.main.get_latest_commit_message", + return_value="fix: solve bug", + ) + mock_bump = mocker.patch("commit_msg_version_bump.main.bump_version") + + cmvb.main() + + mock_bump.assert_not_called() diff --git a/tests/test_control_commit.py b/tests/test_control_commit.py new file mode 100644 index 0000000..0514f54 --- /dev/null +++ b/tests/test_control_commit.py @@ -0,0 +1,182 @@ +# tests/test_control_commit.py + +import subprocess +import sys + +import pytest + +from control_commit.main import ( + add_icon_to_commit_message, + amend_commit, + configure_logger, + has_square_brackets, + main, + read_commit_message, + validate_commit_message, +) + + +@pytest.fixture +def commit_msg_repo(tmp_path, monkeypatch): + """Creates a fake repo layout with .git/COMMIT_EDITMSG and chdirs into it.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".git").mkdir() + + def write(message): + (tmp_path / ".git" / "COMMIT_EDITMSG").write_text(message, encoding="utf-8") + + return write + + +def test_configure_logger_invalid_level_raises(tmp_path, monkeypatch): + """An unknown log level raises ValueError.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="Invalid log level"): + configure_logger("TRACE") + + +@pytest.mark.parametrize( + "message", + [ + "feat: add feature", + "fix(core): handle pagination", + "chore(api-v2): bump deps", + "refactor: extract provider pattern", + "docs: update readme", + ], +) +def test_validate_commit_message_valid(message, tmp_path, monkeypatch): + """Well-formed conventional commit messages are valid.""" + monkeypatch.chdir(tmp_path) + assert validate_commit_message(message) is True + + +@pytest.mark.parametrize( + "message", + [ + "feat: Add feature", # description must start lowercase + "feature: add x", # unknown type + "feat:missing space", # missing space after colon + "feat(Core): add x", # scope must be lowercase + "random text", # no structure at all + ], +) +def test_validate_commit_message_invalid(message, tmp_path, monkeypatch): + """Malformed commit messages are rejected.""" + monkeypatch.chdir(tmp_path) + assert validate_commit_message(message) is False + + +def test_validate_commit_message_allows_bump_version(): + """Version bump commits bypass the conventional structure.""" + assert validate_commit_message("Bump version: 1.0.0 → 1.0.1") is True + + +@pytest.mark.parametrize( + ("commit_type", "icon"), + [("feat", "✨"), ("fix", "🐛"), ("docs", "📝"), ("chore", "🔧"), ("test", "✅")], +) +def test_add_icon_to_commit_message(commit_type, icon): + """The icon matching the commit type is prepended.""" + message = f"{commit_type}: do something" + assert add_icon_to_commit_message(commit_type, message) == f"{icon} {message}" + + +def test_add_icon_keeps_existing_icon(): + """A message that already starts with its icon is not changed.""" + message = "✨ feat: do something" + assert add_icon_to_commit_message("feat", message) == message + + +def test_add_icon_unknown_type_returns_message(): + """An unknown commit type has no icon mapping and leaves the message as is.""" + assert add_icon_to_commit_message("wip", "wip: stuff") == "wip: stuff" + + +def test_has_square_brackets(): + """Square brackets detection for candidate markers.""" + assert has_square_brackets("feat: add x [patch candidate]") is True + assert has_square_brackets("feat: add x") is False + + +def test_read_commit_message_strips_content(tmp_path): + """The commit message file content is returned stripped.""" + msg_file = tmp_path / "COMMIT_EDITMSG" + msg_file.write_text(" feat: add feature \n", encoding="utf-8") + assert read_commit_message(str(msg_file)) == "feat: add feature" + + +def test_read_commit_message_missing_file_exits_1(tmp_path): + """A missing commit message file exits with code 1.""" + with pytest.raises(SystemExit) as exc_info: + read_commit_message(str(tmp_path / "missing")) + assert exc_info.value.code == 1 + + +def test_amend_commit_invokes_git(mocker): + """The commit is amended via git with the new message.""" + mock_run = mocker.patch("control_commit.main.subprocess.run") + amend_commit("✨ feat: add feature") + mock_run.assert_called_once_with( + ["git", "commit", "--amend", "-m", "✨ feat: add feature"], check=True + ) + + +def test_amend_commit_failure_exits_1(mocker): + """A git failure while amending exits with code 1.""" + mocker.patch( + "control_commit.main.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + with pytest.raises(SystemExit) as exc_info: + amend_commit("✨ feat: add feature") + assert exc_info.value.code == 1 + + +def test_main_bump_version_message_exits_0(commit_msg_repo, monkeypatch): + """A version bump commit message is accepted right away.""" + monkeypatch.setattr(sys, "argv", ["control_commit"]) + commit_msg_repo("🔖 Bump version: 1.1.19 → 1.1.20") + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + +def test_main_iconed_valid_message_exits_0(commit_msg_repo, monkeypatch): + """A message that already carries its icon and is valid exits 0.""" + monkeypatch.setattr(sys, "argv", ["control_commit"]) + commit_msg_repo("✨ feat: add feature") + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + +def test_main_iconed_invalid_message_exits_1(commit_msg_repo, monkeypatch): + """A message with icon but invalid structure aborts the commit.""" + monkeypatch.setattr(sys, "argv", ["control_commit"]) + commit_msg_repo("✨ feat: Broken Description") + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + +def test_main_valid_message_without_icon_amends_and_exits_1(commit_msg_repo, monkeypatch, mocker): + """A valid message without icon gets amended with the icon and aborts for review.""" + monkeypatch.setattr(sys, "argv", ["control_commit"]) + mock_amend = mocker.patch("control_commit.main.amend_commit") + commit_msg_repo("feat: add feature") + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + mock_amend.assert_called_once_with("✨ feat: add feature") + + +def test_main_invalid_message_without_icon_exits_1(commit_msg_repo, monkeypatch, mocker): + """An invalid message without icon aborts without amending.""" + monkeypatch.setattr(sys, "argv", ["control_commit"]) + mock_amend = mocker.patch("control_commit.main.amend_commit") + commit_msg_repo("random text") + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + mock_amend.assert_not_called() diff --git a/tests/test_crypto_controller.py b/tests/test_crypto_controller.py index e4fb69e..94d1c45 100644 --- a/tests/test_crypto_controller.py +++ b/tests/test_crypto_controller.py @@ -1,10 +1,13 @@ # tests/test_crypto_controller.py +import base64 import os import json +import sys from unittest import mock import pytest +import requests import shutil import tempfile import logging @@ -13,7 +16,18 @@ from datetime import datetime, timedelta # Import CryptoController and related functions from main.py -from crypto_controller.main import CryptoController, get_key_footprint, Footprint +import crypto_controller.main as cc_main +from crypto_controller.main import ( + CryptoController, + get_key_footprint, + Footprint, + CERT_EXPIRATION_YEARS, + fetch_private_key_password, + send_expiration_alert, + _resolve_key_pair_name, + configure_logger, + parse_arguments, +) # Configure logger for the test module @@ -174,13 +188,13 @@ def crypto_controller_fixture(temp_cert_vault: str) -> CryptoController: def test_env_variables_loaded(mock_load_dotenv, crypto_controller_fixture): """ - Test that environment variables are loaded correctly using load_dotenv. + Test that CryptoController initializes correctly with expected attributes. + load_dotenv is called at module import time so it cannot be asserted via mock here. """ logger.debug("Testing environment variable loading.") - mock_load_dotenv.assert_called_once() assert crypto_controller_fixture.cert_location is not None assert crypto_controller_fixture.key_pair_name == "test_key_pair" - logger.debug("Environment variables loaded and CryptoController initialized correctly.") + logger.debug("CryptoController initialized correctly.") def test_create_cert_vault(crypto_controller_fixture, temp_cert_vault): @@ -294,10 +308,11 @@ def test_verify_success(crypto_controller_fixture: CryptoController, temp_cert_v } logger.debug(f"Created key pair data: {key_pair_data}") - # Mock the encrypt method to return JSON string + # Mock encrypt and decrypt so verify() can round-trip without real RSA keys mocker.patch.object( crypto_controller_fixture, "encrypt", return_value=json.dumps(key_pair_data) ) + mocker.patch.object(crypto_controller_fixture, "decrypt", side_effect=lambda x: x) # Mock get_key_footprint to return matching footprints mocker.patch( "crypto_controller.main.get_key_footprint", @@ -343,10 +358,11 @@ def test_verify_missing_fields( } logger.debug(f"Created incomplete key pair data: {key_pair_data}") - # Mock the encrypt method to return JSON string + # Mock encrypt and decrypt so verify() can round-trip without real RSA keys mocker.patch.object( crypto_controller_fixture, "encrypt", return_value=json.dumps(key_pair_data) ) + mocker.patch.object(crypto_controller_fixture, "decrypt", side_effect=lambda x: x) # Write incomplete key pair data to key pair file with open(crypto_controller_fixture.key_pair_file, "w") as kp_file: @@ -428,17 +444,17 @@ def test_get_status(crypto_controller_fixture: CryptoController, mocker, capsys) assert "Public Key Exists: Yes" in captured.out assert "Private Key Exists: Yes" in captured.out assert "Key Pair File Exists: Yes" in captured.out - assert "Key Verification: True" in captured.out + assert "Key Verification: Yes" in captured.out assert "Expiration: 2025-12-31T23:59:59" in captured.out logger.debug("Verified that get_status output is correct.") def test_fetch_private_key_password(mock_requests_get, crypto_controller_fixture: CryptoController): """ - Test fetching the private key password from a secure API endpoint. + Test fetching the private key password from the module-level function. """ - logger.debug("Testing fetch_private_key_password method.") - password = crypto_controller_fixture.fetch_private_key_password() + logger.debug("Testing fetch_private_key_password function.") + password = fetch_private_key_password() logger.debug(f"Fetched private key password: {password}") assert password == "secure_pass" mock_requests_get.assert_called_once_with( @@ -447,18 +463,18 @@ def test_fetch_private_key_password(mock_requests_get, crypto_controller_fixture "content-type": "application/json", "token_security": "mocked_secure_token", }, - timeout=5, # Changed from "5" to 5 (integer) + timeout=5, ) logger.debug("Verified that fetch_private_key_password fetched the correct password.") def test_send_expiration_alert(mock_smtp, crypto_controller_fixture: CryptoController): """ - Test sending an expiration alert email. + Test sending an expiration alert email via the module-level function. """ - logger.debug("Testing send_expiration_alert method.") + logger.debug("Testing send_expiration_alert function.") expiration_date = datetime.now() + timedelta(days=30) - crypto_controller_fixture.send_expiration_alert(expiration_date) + send_expiration_alert(expiration_date) logger.debug("Called send_expiration_alert method.") # Verify SMTP interactions @@ -513,6 +529,39 @@ def test_renew_keys_confirm_no(crypto_controller_fixture: CryptoController, mock ) +def test_resolve_key_pair_name_finds_valid_range(tmp_path): + """Finds existing .kp whose year range contains current year.""" + current_year = datetime.now().year + kp_name = f"Crypto-Key-Pair-{current_year - 1}-{current_year + 5}.kp" + (tmp_path / kp_name).write_text("dummy") + result = _resolve_key_pair_name(str(tmp_path)) + assert result == kp_name[:-3] + + +def test_resolve_key_pair_name_no_certs_fallback(tmp_path): + """Falls back to new range-based name when no valid .kp exists.""" + current_year = datetime.now().year + result = _resolve_key_pair_name(str(tmp_path)) + assert result == f"Crypto-Key-Pair-{current_year}-{current_year + int(CERT_EXPIRATION_YEARS)}" + + +def test_resolve_key_pair_name_expired_range_ignored(tmp_path): + """Ignores .kp file whose range has already expired.""" + (tmp_path / "Crypto-Key-Pair-2020-2021.kp").write_text("dummy") + current_year = datetime.now().year + result = _resolve_key_pair_name(str(tmp_path)) + assert result == f"Crypto-Key-Pair-{current_year}-{current_year + int(CERT_EXPIRATION_YEARS)}" + + +def test_resolve_key_pair_name_simulates_2027(tmp_path): + """In 2027, finds Crypto-Key-Pair-2026-2032 since 2026 <= 2027 <= 2032.""" + (tmp_path / "Crypto-Key-Pair-2026-2032.kp").write_text("dummy") + with patch("crypto_controller.main.datetime") as mock_dt: + mock_dt.now.return_value = datetime(2027, 1, 1) + result = _resolve_key_pair_name(str(tmp_path)) + assert result == "Crypto-Key-Pair-2026-2032" + + def test_renew_keys_invalid_confirmation(crypto_controller_fixture: CryptoController, mocker): """ Test renewing keys with invalid user input. @@ -535,3 +584,457 @@ def test_renew_keys_invalid_confirmation(crypto_controller_fixture: CryptoContro logger.debug( "Verified that clean_cert_vault and create_keys were not called and sys.exit was called with 1." ) + + +# Extended coverage: logger configuration, footprints, vault management, +# hybrid encryption error paths, verify branches and the CLI entry point. + + +def test_configure_logger_invalid_level_raises(): + """An unknown log level raises ValueError before touching any handler.""" + with pytest.raises(ValueError, match="Invalid log level"): + configure_logger("WHISPER") + + +def test_configure_logger_sets_handlers(tmp_path, monkeypatch): + """A valid level configures rotating file and console handlers.""" + monkeypatch.chdir(tmp_path) + configure_logger("DEBUG") + assert cc_main.logger.level == logging.DEBUG + assert len(cc_main.logger.handlers) == 2 + + +def _write_valid_pem(path: str, key_type: str) -> None: + """Writes a PEM file whose body is valid base64 so footprints can be computed.""" + der_body = base64.encodebytes(b"dummy der payload for footprint tests") + if key_type == "public": + begin, end = b"-----BEGIN PUBLIC KEY-----\n", b"-----END PUBLIC KEY-----\n" + else: + begin, end = ( + b"-----BEGIN ENCRYPTED PRIVATE KEY-----\n", + b"-----END ENCRYPTED PRIVATE KEY-----\n", + ) + with open(path, "wb") as pem_file: + pem_file.write(begin + der_body + end) + + +def test_get_key_footprint_public_and_private(temp_cert_vault): + """Footprints are generated for valid public and private PEM files.""" + public_path = os.path.join(temp_cert_vault, "key.pub") + private_path = os.path.join(temp_cert_vault, "key.key") + _write_valid_pem(public_path, "public") + _write_valid_pem(private_path, "private") + + public_fp = get_key_footprint(public_path, "public") + private_fp = get_key_footprint(private_path, "private") + + for footprint in (public_fp, private_fp): + assert len(footprint.sha1) == 40 + assert len(footprint.sha256) == 64 + + +def test_get_key_footprint_invalid_type_raises(temp_cert_vault): + """An unknown key type raises ValueError.""" + path = os.path.join(temp_cert_vault, "key.pub") + create_dummy_pem(path, "public") + with pytest.raises(ValueError, match="Invalid key type"): + get_key_footprint(path, "ssh") + + +def test_get_key_footprint_invalid_pem_raises(temp_cert_vault): + """A file without PEM markers raises IOError.""" + path = os.path.join(temp_cert_vault, "not_a_key.txt") + with open(path, "w") as plain_file: + plain_file.write("plain text, no markers") + with pytest.raises(IOError, match="Not a valid PEM file"): + get_key_footprint(path, "public") + + +def test_check_cert_vault_exists(crypto_controller_fixture, temp_cert_vault): + """The vault existence check reflects the filesystem state.""" + assert crypto_controller_fixture.check_cert_vault_exists() is True + shutil.rmtree(temp_cert_vault) + assert crypto_controller_fixture.check_cert_vault_exists() is False + crypto_controller_fixture.create_cert_vault() # restore for fixture teardown + + +def test_create_cert_vault_failure_raises(crypto_controller_fixture, mocker): + """A filesystem error while creating the vault is re-raised.""" + mocker.patch("crypto_controller.main.os.makedirs", side_effect=PermissionError("denied")) + with pytest.raises(PermissionError): + crypto_controller_fixture.create_cert_vault() + + +def test_clean_cert_vault_recreates_empty_vault(crypto_controller_fixture, temp_cert_vault): + """Cleaning removes vault contents and recreates the directory.""" + leftover = os.path.join(temp_cert_vault, "old.key") + with open(leftover, "w") as old_file: + old_file.write("old") + crypto_controller_fixture.clean_cert_vault() + assert os.path.exists(temp_cert_vault) + assert os.listdir(temp_cert_vault) == [] + + +def test_clean_cert_vault_failure_raises(crypto_controller_fixture, mocker): + """A filesystem error while cleaning the vault is re-raised.""" + mocker.patch("crypto_controller.main.shutil.rmtree", side_effect=OSError("busy")) + with pytest.raises(OSError): + crypto_controller_fixture.clean_cert_vault() + + +def test_encrypt_hybrid_failure_raises(crypto_controller_fixture, mocker): + """A key loading failure during encryption is re-raised.""" + mocker.patch.object(crypto_controller_fixture, "load_keys", side_effect=RuntimeError("no keys")) + with pytest.raises(RuntimeError): + crypto_controller_fixture.encrypt_hybrid("secret") + + +def test_decrypt_hybrid_bad_format_exits_1(crypto_controller_fixture, mocker): + """Encrypted data without the three-part format exits with code 1.""" + mocker.patch.object( + crypto_controller_fixture, "load_keys", return_value=(mock.Mock(), mock.Mock()) + ) + with pytest.raises(SystemExit) as exc_info: + crypto_controller_fixture.decrypt_hybrid("not-three-parts") + assert exc_info.value.code == 1 + + +def test_decrypt_reraises_unexpected_errors(crypto_controller_fixture, mocker): + """decrypt re-raises when decrypt_hybrid fails without exiting.""" + mocker.patch.object(crypto_controller_fixture, "decrypt_hybrid", side_effect=ValueError("boom")) + with pytest.raises(ValueError): + crypto_controller_fixture.decrypt("anything") + + +def _prepare_verify_data(controller, mocker, **overrides): + """Writes a key pair file with identity-decrypted JSON data for verify tests.""" + create_dummy_pem(controller.public_key_file, "public") + create_dummy_pem(controller.private_key_file, "private") + key_pair_data = { + "public_key_file": controller.public_key_file, + "public_fp_sha1": "sha1", + "public_fp_sha256": "sha256", + "private_key_file": controller.private_key_file, + "private_fp_sha1": "sha1", + "private_fp_sha256": "sha256", + "key_pair_file": controller.key_pair_file, + "creation_date": datetime.now().strftime("%d%m%Y%H%M%S"), + "expiration_date": (datetime.now() + timedelta(days=365)).strftime("%d%m%Y%H%M%S"), + } + key_pair_data.update(overrides) + mocker.patch.object(controller, "decrypt", side_effect=lambda x: x) + with open(controller.key_pair_file, "w") as kp_file: + kp_file.write(json.dumps(key_pair_data)) + return key_pair_data + + +def test_verify_missing_public_key_file(crypto_controller_fixture, mocker): + """verify fails when the referenced public key file does not exist.""" + _prepare_verify_data( + crypto_controller_fixture, + mocker, + public_key_file=crypto_controller_fixture.public_key_file + ".missing", + ) + assert crypto_controller_fixture.verify() is False + + +def test_verify_missing_private_key_file(crypto_controller_fixture, mocker): + """verify fails when the referenced private key file does not exist.""" + _prepare_verify_data( + crypto_controller_fixture, + mocker, + private_key_file=crypto_controller_fixture.private_key_file + ".missing", + ) + assert crypto_controller_fixture.verify() is False + + +def test_verify_public_fingerprint_mismatch(crypto_controller_fixture, mocker): + """verify fails when the public key fingerprints do not match.""" + _prepare_verify_data(crypto_controller_fixture, mocker) + mocker.patch( + "crypto_controller.main.get_key_footprint", return_value=Footprint("other", "other") + ) + assert crypto_controller_fixture.verify() is False + + +def test_verify_private_fingerprint_mismatch(crypto_controller_fixture, mocker): + """verify fails when the private key fingerprints do not match.""" + _prepare_verify_data(crypto_controller_fixture, mocker) + mocker.patch( + "crypto_controller.main.get_key_footprint", + side_effect=[Footprint("sha1", "sha256"), Footprint("other", "other")], + ) + assert crypto_controller_fixture.verify() is False + + +def test_verify_key_pair_path_mismatch(crypto_controller_fixture, mocker): + """verify fails when the key pair file path differs from the expected one.""" + _prepare_verify_data(crypto_controller_fixture, mocker, key_pair_file="/elsewhere/kp.kp") + mocker.patch( + "crypto_controller.main.get_key_footprint", return_value=Footprint("sha1", "sha256") + ) + assert crypto_controller_fixture.verify() is False + + +def test_verify_expired_key_pair(crypto_controller_fixture, mocker): + """verify fails when the key pair expiration date is in the past.""" + _prepare_verify_data( + crypto_controller_fixture, + mocker, + expiration_date=(datetime.now() - timedelta(days=1)).strftime("%d%m%Y%H%M%S"), + ) + mocker.patch( + "crypto_controller.main.get_key_footprint", return_value=Footprint("sha1", "sha256") + ) + assert crypto_controller_fixture.verify() is False + + +def test_get_expiration_returns_iso_date(crypto_controller_fixture, mocker): + """The expiration date is returned in ISO format.""" + with open(crypto_controller_fixture.key_pair_file, "w") as kp_file: + kp_file.write("encrypted") + mocker.patch.object( + crypto_controller_fixture, + "decrypt", + return_value=json.dumps({"expiration_date": "31122026235959"}), + ) + assert crypto_controller_fixture.get_expiration() == "2026-12-31T23:59:59" + + +def test_get_expiration_missing_field_returns_unknown(crypto_controller_fixture, mocker): + """A key pair payload without expiration date returns 'Unknown'.""" + with open(crypto_controller_fixture.key_pair_file, "w") as kp_file: + kp_file.write("encrypted") + mocker.patch.object(crypto_controller_fixture, "decrypt", return_value="{}") + assert crypto_controller_fixture.get_expiration() == "Unknown" + + +def test_get_expiration_failure_returns_unknown(crypto_controller_fixture): + """A missing key pair file returns 'Unknown'.""" + assert crypto_controller_fixture.get_expiration() == "Unknown" + + +def test_create_keys_skips_when_verification_passes(crypto_controller_fixture, mocker): + """create_keys is a no-op when the existing keys verify correctly.""" + mocker.patch.object(crypto_controller_fixture, "verify", return_value=True) + mock_generate = mocker.patch("crypto_controller.main.rsa.generate_private_key") + crypto_controller_fixture.create_keys() + mock_generate.assert_not_called() + + +def test_create_keys_failure_raises(crypto_controller_fixture, mocker): + """A key generation failure is re-raised.""" + mocker.patch.object(crypto_controller_fixture, "verify", return_value=False) + mocker.patch( + "crypto_controller.main.rsa.generate_private_key", side_effect=RuntimeError("rsa boom") + ) + with pytest.raises(RuntimeError): + crypto_controller_fixture.create_keys() + + +def test_renew_keys_failure_exits_1(crypto_controller_fixture, mocker): + """A failure while renewing keys exits with code 1.""" + mocker.patch("crypto_controller.main.input", return_value="yes") + mocker.patch.object( + crypto_controller_fixture, "clean_cert_vault", side_effect=RuntimeError("disk") + ) + with pytest.raises(SystemExit) as exc_info: + crypto_controller_fixture.renew_keys() + assert exc_info.value.code == 1 + + +def test_load_keys_auto_creates_missing_keys(crypto_controller_fixture): + """load_keys regenerates real key material when the vault is empty.""" + public_key, private_key = crypto_controller_fixture.load_keys() + assert public_key is not None + assert private_key is not None + # A second call now loads the freshly created keys directly + public_key_again, private_key_again = crypto_controller_fixture.load_keys() + assert public_key_again is not None + assert private_key_again is not None + + +def test_get_status_failure_prints_fallback(crypto_controller_fixture, mocker, capsys): + """A status failure prints the fallback message instead of raising.""" + mocker.patch.object( + crypto_controller_fixture, "check_cert_vault_exists", side_effect=RuntimeError("boom") + ) + crypto_controller_fixture.get_status() + assert "Failed to retrieve status" in capsys.readouterr().out + + +def test_parse_arguments_defaults(monkeypatch): + """parse_arguments resolves defaults for vault location and key pair name.""" + monkeypatch.setattr(sys, "argv", ["crypto_controller", "status"]) + args = parse_arguments() + assert args.operation == "status" + assert args.value is None + assert args.cert_location.endswith("certs") + assert args.key_pair_name.startswith("Crypto-Key-Pair-") + assert args.log_level == "INFO" + + +def test_parse_arguments_custom_values(monkeypatch): + """parse_arguments honors explicit values for every option.""" + monkeypatch.setattr( + sys, + "argv", + [ + "crypto_controller", + "encrypt", + "secret", + "--cert-location", + "/x", + "--key-pair-name", + "kp", + "--log-level", + "DEBUG", + ], + ) + args = parse_arguments() + assert args.operation == "encrypt" + assert args.value == "secret" + assert args.cert_location == "/x" + assert args.key_pair_name == "kp" + assert args.log_level == "DEBUG" + + +def test_parse_arguments_invalid_operation_exits_2(monkeypatch): + """An unknown operation makes argparse exit with code 2.""" + monkeypatch.setattr(sys, "argv", ["crypto_controller", "explode"]) + with pytest.raises(SystemExit) as exc_info: + parse_arguments() + assert exc_info.value.code == 2 + + +def test_fetch_private_key_password_falls_back_to_env(mocker, monkeypatch): + """API failures fall back to the KP_PASSWORD environment variable.""" + mocker.patch( + "crypto_controller.main.requests.get", + side_effect=requests.exceptions.RequestException("api down"), + ) + monkeypatch.setenv("KP_PASSWORD", "env_pass") + assert fetch_private_key_password() == "env_pass" + + +def test_send_expiration_alert_incomplete_config_skips_send(mock_smtp, monkeypatch): + """Without full SMTP configuration no email is attempted.""" + monkeypatch.delenv("SMTP_SERVER") + send_expiration_alert(datetime.now() + timedelta(days=5)) + mock_smtp.assert_not_called() + + +def test_send_expiration_alert_smtp_failure_does_not_raise(mock_smtp): + """SMTP failures are logged without raising.""" + mock_smtp.side_effect = RuntimeError("smtp down") + send_expiration_alert(datetime.now() + timedelta(days=5)) + + +# CLI main() coverage + + +@pytest.fixture +def cli_controller(tmp_path, monkeypatch, mocker): + """Mocks the controller and password fetch for CLI main() tests.""" + monkeypatch.chdir(tmp_path) + mocker.patch("crypto_controller.main.fetch_private_key_password", return_value="pwd") + mock_controller = mock.Mock() + mocker.patch("crypto_controller.main.CryptoController", return_value=mock_controller) + return mock_controller + + +def _run_cli(monkeypatch, *argv): + """Runs the module CLI with the given arguments.""" + monkeypatch.setattr(sys, "argv", ["crypto_controller", *argv]) + cc_main.main() + + +def test_main_init_with_existing_vault(cli_controller, monkeypatch): + """init with an existing vault only creates new keys.""" + cli_controller.check_cert_vault_exists.return_value = True + _run_cli(monkeypatch, "init") + cli_controller.create_keys.assert_called_once() + cli_controller.create_cert_vault.assert_not_called() + + +def test_main_init_creates_vault(cli_controller, monkeypatch): + """init without a vault creates the vault and the keys.""" + cli_controller.check_cert_vault_exists.return_value = False + _run_cli(monkeypatch, "init") + cli_controller.create_cert_vault.assert_called_once() + cli_controller.create_keys.assert_called_once() + + +def test_main_renew_with_existing_vault(cli_controller, monkeypatch): + """renew with an existing vault delegates to renew_keys.""" + cli_controller.check_cert_vault_exists.return_value = True + _run_cli(monkeypatch, "renew") + cli_controller.renew_keys.assert_called_once() + + +def test_main_renew_without_vault_creates_keys(cli_controller, monkeypatch): + """renew without a vault creates the vault and the keys.""" + cli_controller.check_cert_vault_exists.return_value = False + _run_cli(monkeypatch, "renew") + cli_controller.create_cert_vault.assert_called_once() + cli_controller.create_keys.assert_called_once() + + +def test_main_encrypt_without_value_exits_1(cli_controller, monkeypatch): + """encrypt without a value exits with code 1.""" + with pytest.raises(SystemExit) as exc_info: + _run_cli(monkeypatch, "encrypt") + assert exc_info.value.code == 1 + + +def test_main_encrypt_prints_result(cli_controller, monkeypatch, capsys): + """encrypt prints the encrypted value when verification passes.""" + cli_controller.verify.return_value = True + cli_controller.encrypt.return_value = "ENCRYPTED" + _run_cli(monkeypatch, "encrypt", "secret") + assert "ENCRYPTED" in capsys.readouterr().out + + +def test_main_encrypt_with_failed_verification_exits_1(cli_controller, monkeypatch): + """encrypt aborts with code 1 when verification fails.""" + cli_controller.verify.return_value = False + with pytest.raises(SystemExit) as exc_info: + _run_cli(monkeypatch, "encrypt", "secret") + assert exc_info.value.code == 1 + + +def test_main_decrypt_without_value_exits_1(cli_controller, monkeypatch): + """decrypt without a value exits with code 1.""" + with pytest.raises(SystemExit) as exc_info: + _run_cli(monkeypatch, "decrypt") + assert exc_info.value.code == 1 + + +def test_main_decrypt_prints_result(cli_controller, monkeypatch, capsys): + """decrypt prints the plain value when verification passes.""" + cli_controller.verify.return_value = True + cli_controller.decrypt.return_value = "PLAIN" + _run_cli(monkeypatch, "decrypt", "payload") + assert "PLAIN" in capsys.readouterr().out + + +def test_main_decrypt_with_failed_verification_exits_1(cli_controller, monkeypatch): + """decrypt aborts with code 1 when verification fails.""" + cli_controller.verify.return_value = False + with pytest.raises(SystemExit) as exc_info: + _run_cli(monkeypatch, "decrypt", "payload") + assert exc_info.value.code == 1 + + +def test_main_status_invokes_get_status(cli_controller, monkeypatch): + """status delegates to get_status.""" + _run_cli(monkeypatch, "status") + cli_controller.get_status.assert_called_once() + + +def test_main_operation_errors_are_logged_not_raised(cli_controller, monkeypatch, caplog): + """Operation failures are logged and main() returns normally.""" + cli_controller.check_cert_vault_exists.side_effect = RuntimeError("boom") + with caplog.at_level(logging.ERROR, logger="__main__"): + _run_cli(monkeypatch, "init") + assert "Operation 'init' failed" in caplog.text diff --git a/tests/test_format_yaml.py b/tests/test_format_yaml.py new file mode 100644 index 0000000..1a5c49a --- /dev/null +++ b/tests/test_format_yaml.py @@ -0,0 +1,120 @@ +# tests/test_format_yaml.py + +import sys + +import pytest + +from format_yaml.main import format_yaml_file, main + + +def _write(tmp_path, name, content): + file_path = tmp_path / name + file_path.write_text(content, encoding="utf-8") + return str(file_path) + + +def test_adds_document_start_marker(tmp_path, capsys): + """A yaml file without '---' gets the marker prepended.""" + file_path = _write(tmp_path, "workflow.yaml", "key: value\n") + format_yaml_file(file_path) + content = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + assert content.startswith("---\n") + assert "Added '---' at the beginning" in capsys.readouterr().out + + +def test_keeps_existing_marker(tmp_path, capsys): + """A yaml file that already starts with '---' is not modified.""" + file_path = _write(tmp_path, "workflow.yaml", "---\nkey: value\n") + format_yaml_file(file_path) + content = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + assert content == "---\nkey: value\n" + assert "Added '---'" not in capsys.readouterr().out + + +def test_removes_extra_spaces_inside_brackets(tmp_path, capsys): + """Spaces right after '[' / '(' and before ']' / ')' are removed.""" + file_path = _write(tmp_path, "workflow.yaml", "---\nlist: [ a, b ]\ncmd: ( x )\n") + format_yaml_file(file_path) + content = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + assert "list: [a, b]" in content + assert "cmd: (x)" in content + assert "Removed extra spaces inside brackets" in capsys.readouterr().out + + +def test_splits_long_line_inside_run_block(tmp_path, capsys): + """Long lines inside a 'run: |' block are split and the split reaches the output file.""" + long_line = " " + ("word " * 30).strip() # 153 chars, indented + content = f"---\nrun: |\n{long_line}\ndone: true\n" + file_path = _write(tmp_path, "workflow.yaml", content) + format_yaml_file(file_path) + result = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + lines = result.splitlines() + assert len(lines) == 5 # ---, run: |, two split parts, done: true + assert lines[2].startswith(" word") + assert lines[3].startswith(" word") + assert result.count("word") == 30 # no content lost in the split + assert "Split long line in 'run' block" in capsys.readouterr().out + + +def test_force_splits_run_block_line_without_spaces(tmp_path, capsys): + """A run-block line with no spaces is hard-split at column 120.""" + long_line = "\t" + "a" * 130 # tab keeps it inside the run block + content = f"---\nrun: |\n{long_line}\n" + file_path = _write(tmp_path, "workflow.yaml", content) + format_yaml_file(file_path) + result = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + lines = result.splitlines() + assert lines[2] == long_line[:120] + assert lines[3] == " " + long_line[120:] + assert "Split long line in 'run' block" in capsys.readouterr().out + + +def test_long_line_outside_run_block_is_untouched(tmp_path): + """Lines longer than 120 chars outside run blocks are not split.""" + long_line = "key: " + "a" * 130 + file_path = _write(tmp_path, "workflow.yaml", f"---\n{long_line}\n") + format_yaml_file(file_path) + content = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + assert content == f"---\n{long_line}\n" + + +def test_non_indented_line_exits_run_block(tmp_path): + """A non-indented line closes the run block, so later long lines are untouched.""" + long_line = " " + ("word " * 30).strip() + content = f"---\nrun: |\n short\ndone: true\n{long_line}\n" + file_path = _write(tmp_path, "workflow.yaml", content) + format_yaml_file(file_path) + result = (tmp_path / "workflow.yaml").read_text(encoding="utf-8") + assert long_line in result.splitlines() # still one single line + + +def test_main_processes_all_files(monkeypatch, tmp_path, capsys): + """main() formats every file passed, not only the first one.""" + file_a = _write(tmp_path, "a.yaml", "key: a\n") + file_b = _write(tmp_path, "b.yaml", "key: b\n") + monkeypatch.setattr(sys, "argv", ["format_yaml", file_a, file_b]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + assert (tmp_path / "a.yaml").read_text(encoding="utf-8").startswith("---\n") + assert (tmp_path / "b.yaml").read_text(encoding="utf-8").startswith("---\n") + assert "All checks passed successfully." in capsys.readouterr().out + + +def test_main_without_arguments_exits_1(monkeypatch, capsys): + """main() exits with code 1 when no files are passed.""" + monkeypatch.setattr(sys, "argv", ["format_yaml"]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "Incorrect usage" in capsys.readouterr().out + + +def test_main_missing_file_exits_1(monkeypatch, tmp_path, capsys): + """main() exits with code 1 when a file does not exist.""" + missing = str(tmp_path / "nope.yaml") + monkeypatch.setattr(sys, "argv", ["format_yaml", missing]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "does not exist" in capsys.readouterr().out diff --git a/tests/test_format_yml.py b/tests/test_format_yml.py new file mode 100644 index 0000000..b4de539 --- /dev/null +++ b/tests/test_format_yml.py @@ -0,0 +1,109 @@ +# tests/test_format_yml.py + +import sys + +import pytest + +from format_yml.main import format_yml_file, main + + +def _write(tmp_path, name, content): + file_path = tmp_path / name + file_path.write_text(content, encoding="utf-8") + return str(file_path) + + +def test_adds_document_start_marker(tmp_path, capsys): + """A yml file without '---' gets the marker prepended.""" + file_path = _write(tmp_path, "config.yml", "key: value\n") + format_yml_file(file_path) + content = (tmp_path / "config.yml").read_text(encoding="utf-8") + assert content.startswith("---\n") + assert "Added '---' at the beginning" in capsys.readouterr().out + + +def test_keeps_existing_marker(tmp_path, capsys): + """A yml file that already starts with '---' is not modified.""" + file_path = _write(tmp_path, "config.yml", "---\nkey: value\n") + format_yml_file(file_path) + content = (tmp_path / "config.yml").read_text(encoding="utf-8") + assert content == "---\nkey: value\n" + assert "Added '---'" not in capsys.readouterr().out + + +def test_removes_extra_spaces_inside_brackets(tmp_path, capsys): + """Spaces right after '[' / '(' and before ']' / ')' are removed.""" + file_path = _write(tmp_path, "config.yml", "---\nlist: [ a, b ]\ncmd: ( x )\n") + format_yml_file(file_path) + content = (tmp_path / "config.yml").read_text(encoding="utf-8") + assert "list: [a, b]" in content + assert "cmd: (x)" in content + assert "Removed extra spaces inside brackets" in capsys.readouterr().out + + +def test_splits_long_line_and_terminates(tmp_path, capsys): + """Lines longer than 120 chars are split into ' \\' continuations and the loop ends.""" + long_line = "key: " + ("abc " * 40).strip() # 164 chars with plenty of spaces + file_path = _write(tmp_path, "config.yml", f"---\n{long_line}\n") + format_yml_file(file_path) + content = (tmp_path / "config.yml").read_text(encoding="utf-8") + lines = content.splitlines() + assert len(lines) >= 3 # marker + at least two split parts + assert lines[1].endswith(" \\") + assert all(len(line) <= 122 for line in lines) + assert content.count("abc") == 40 # no content lost in the split + assert "Split long line" in capsys.readouterr().out + + +def test_force_splits_long_line_without_spaces(tmp_path, capsys): + """A long line without spaces is hard-split at column 120.""" + long_line = "key" + "a" * 130 + file_path = _write(tmp_path, "config.yml", f"---\n{long_line}\n") + format_yml_file(file_path) + content = (tmp_path / "config.yml").read_text(encoding="utf-8") + lines = content.splitlines() + assert lines[1] == long_line[:120] + " \\" + assert lines[2].lstrip() == long_line[120:] + assert "Force split long line" in capsys.readouterr().out + + +def test_line_at_exactly_120_chars_is_untouched(tmp_path): + """A line of exactly 120 chars is not split.""" + exact_line = "k: " + "a" * 117 + assert len(exact_line) == 120 + file_path = _write(tmp_path, "config.yml", f"---\n{exact_line}\n") + format_yml_file(file_path) + content = (tmp_path / "config.yml").read_text(encoding="utf-8") + assert content == f"---\n{exact_line}\n" + + +def test_main_processes_all_files(monkeypatch, tmp_path, capsys): + """main() formats every file passed, not only the first one.""" + file_a = _write(tmp_path, "a.yml", "key: a\n") + file_b = _write(tmp_path, "b.yml", "key: b\n") + monkeypatch.setattr(sys, "argv", ["format_yml", file_a, file_b]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + assert (tmp_path / "a.yml").read_text(encoding="utf-8").startswith("---\n") + assert (tmp_path / "b.yml").read_text(encoding="utf-8").startswith("---\n") + assert "All checks passed successfully." in capsys.readouterr().out + + +def test_main_without_arguments_exits_1(monkeypatch, capsys): + """main() exits with code 1 when no files are passed.""" + monkeypatch.setattr(sys, "argv", ["format_yml"]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "Incorrect usage" in capsys.readouterr().out + + +def test_main_missing_file_exits_1(monkeypatch, tmp_path, capsys): + """main() exits with code 1 when a file does not exist.""" + missing = str(tmp_path / "nope.yml") + monkeypatch.setattr(sys, "argv", ["format_yml", missing]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "does not exist" in capsys.readouterr().out diff --git a/tests/test_generate_changelog.py b/tests/test_generate_changelog.py new file mode 100644 index 0000000..6f8a7c9 --- /dev/null +++ b/tests/test_generate_changelog.py @@ -0,0 +1,447 @@ +# tests/test_generate_changelog.py + +import logging +import subprocess +import sys +from collections import OrderedDict +from datetime import datetime, timezone + +import pytest + +import generate_changelog.main as gcl +from generate_changelog.main import ( + compare_versions, + fetch_tags, + generate_changelog_entry, + generate_full_changelog, + get_all_commits, + get_commits_between_tags, + get_commits_since_last_tag, + get_sorted_tags, + get_tag_date, + get_tag_dates, + is_noise_commit, + parse_arguments, + parse_commits, + parse_version, + update_changelog, +) + +TODAY_UTC = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +def test_parse_arguments_defaults(monkeypatch): + """parse_arguments defaults to INFO log level.""" + monkeypatch.setattr(sys, "argv", ["generate_changelog"]) + assert parse_arguments().log_level == "INFO" + + +def test_configure_logger_invalid_level_raises(tmp_path, monkeypatch): + """An unknown log level raises ValueError.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="Invalid log level"): + gcl.configure_logger("CHATTY") + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("v1.2.3", (1, 2, 3)), + ("1.0.8-test", (1, 0, 8)), + ("v10.20.30", (10, 20, 30)), + ("garbage", (0, 0, 0)), + ("1.2", (0, 0, 0)), + ], +) +def test_parse_version(version, expected): + """Version strings are parsed into numeric tuples, invalid ones become zeros.""" + assert parse_version(version) == expected + + +@pytest.mark.parametrize( + ("v1", "v2", "expected"), + [ + ("v2.0.0", "v1.9.9", 1), + ("v1.0.0", "v1.0.1", -1), + ("1.2.3", "1.2.3", 0), + ("v1.10.0", "v1.9.0", 1), # numeric, not lexicographic + ("v1.0.10", "v1.0.9", 1), + ("v1.1.0", "v1.2.0", -1), + ], +) +def test_compare_versions(v1, v2, expected): + """Semantic comparison across major, minor and patch.""" + assert compare_versions(v1, v2) == expected + + +@pytest.mark.parametrize( + ("commit", "expected"), + [ + ("🔖 Bump version: 1.0.0 → 1.0.1", True), + ("Merge branch 'dev' into testing", True), + ("Merge pull request #12 from fork/dev", True), + ("feat: add feature", False), + ], +) +def test_is_noise_commit(commit, expected): + """Bump and merge commits are noise; regular commits are not.""" + assert is_noise_commit(commit) is expected + + +def test_parse_commits_categorizes_by_type(): + """Commits are categorized, emojis stripped, noise skipped, extras collected.""" + commits = [ + "✨ feat(core): add support [minor candidate]", + "fix: solve bug", + "random words", + "Merge branch 'dev'", + ] + changelog, non_conforming = parse_commits(commits) + assert changelog["### Features"] == ["- **core**: add support (`minor candidate`)"] + assert changelog["### Bug Fixes"] == ["- solve bug"] + assert "### Chores" not in changelog # empty sections are dropped + assert non_conforming == ["random words"] + + +def test_parse_commits_empty_input(): + """No commits produce an empty changelog and no leftovers.""" + changelog, non_conforming = parse_commits([]) + assert changelog == {} + assert non_conforming == [] + + +def test_generate_changelog_entry_with_date(): + """The entry renders header, sections and other changes.""" + entry = generate_changelog_entry( + "1.1.0", + {"### Features": ["- **core**: add support"]}, + ["mystery commit"], + "2026-01-05", + ) + assert entry.startswith("## [1.1.0] - 2026-01-05\n") + assert "### Features\n- **core**: add support\n" in entry + assert "### Other Changes\n- mystery commit\n" in entry + + +def test_generate_changelog_entry_defaults_to_today(): + """Without an explicit date the entry is stamped with today (UTC).""" + entry = generate_changelog_entry("1.0.0", {}, []) + assert entry.startswith(f"## [1.0.0] - {TODAY_UTC}") + + +def test_generate_full_changelog_orders_latest_first_and_skips_empty(): + """Versions are rendered newest first; tagless versions without commits are skipped.""" + commits_dict = OrderedDict( + [("1.0.0", ["feat: first"]), ("1.1.0", ["fix: second"]), ("2.0.0", [])] + ) + tag_dates = {"1.0.0": "2026-01-01", "1.1.0": "2026-02-01"} + content = generate_full_changelog(commits_dict, tag_dates) + assert "## [2.0.0]" not in content + assert content.index("## [1.1.0] - 2026-02-01") < content.index("## [1.0.0] - 2026-01-01") + + +def test_fetch_tags_invokes_git(mocker): + """Tags are fetched from the remote.""" + mock_output = mocker.patch("generate_changelog.main.subprocess.check_output") + fetch_tags() + mock_output.assert_called_once_with(["git", "fetch", "--tags"]) + + +def test_fetch_tags_failure_only_warns(mocker, caplog): + """A fetch failure logs a warning and does not raise.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + with caplog.at_level(logging.WARNING, logger="generate_changelog.main"): + fetch_tags() + assert "Could not fetch Git tags" in caplog.text + + +def test_get_tag_date(mocker): + """The tag date is extracted from the git log output.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + return_value="2026-01-02 10:00:00 -0500\n", + ) + assert get_tag_date("v1.0.0") == "2026-01-02" + + +def test_get_tag_date_failure_falls_back_to_today(mocker): + """A git failure falls back to today's date.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + assert get_tag_date("v1.0.0") == TODAY_UTC + + +def test_get_sorted_tags_filters_and_sorts_semantically(mocker): + """Only vX.Y.Z tags are kept, sorted numerically ascending.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + return_value="v1.0.10\nv1.0.2\nv2.0.0\nfoo\nv1.0.2-test\n", + ) + assert get_sorted_tags() == ["v1.0.2", "v1.0.10", "v2.0.0"] + + +def test_get_sorted_tags_without_semantic_tags_returns_empty(mocker, caplog): + """Without semantic tags an empty list is returned with a warning.""" + mocker.patch("generate_changelog.main.subprocess.check_output", return_value="foo\nbar\n") + with caplog.at_level(logging.WARNING, logger="generate_changelog.main"): + assert get_sorted_tags() == [] + assert "No semantic Git tags found" in caplog.text + + +def test_get_sorted_tags_failure_returns_empty(mocker): + """A git failure returns an empty list.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + assert get_sorted_tags() == [] + + +def test_get_commits_between_tags(mocker): + """Commits between two tags are listed using the tag range.""" + mock_output = mocker.patch( + "generate_changelog.main.subprocess.check_output", + return_value=b"feat: a\nfix: b\n", + ) + assert get_commits_between_tags("v1.0.0", "v1.1.0") == ["feat: a", "fix: b"] + mock_output.assert_called_once_with(["git", "log", "v1.0.0..v1.1.0", "--pretty=format:%s"]) + + +def test_get_commits_between_tags_without_old_tag(mocker): + """Without an old tag, every commit up to the new tag is listed.""" + mock_output = mocker.patch( + "generate_changelog.main.subprocess.check_output", + return_value=b"feat: a\n", + ) + assert get_commits_between_tags("", "v1.0.0") == ["feat: a"] + mock_output.assert_called_once_with(["git", "log", "v1.0.0", "--pretty=format:%s"]) + + +def test_get_commits_between_tags_failure_returns_empty(mocker): + """A git failure returns an empty list.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + assert get_commits_between_tags("v1.0.0", "v1.1.0") == [] + + +def test_get_commits_since_last_tag_without_tags(mocker): + """Without tags, all repository commits are returned.""" + mock_output = mocker.patch( + "generate_changelog.main.subprocess.check_output", + return_value=b"feat: a\nfix: b\n", + ) + assert get_commits_since_last_tag([]) == ["feat: a", "fix: b"] + mock_output.assert_called_once_with(["git", "log", "--pretty=format:%s"]) + + +def test_get_commits_since_last_tag_uses_latest_tag(mocker): + """With tags, only commits after the latest tag are returned.""" + mock_output = mocker.patch( + "generate_changelog.main.subprocess.check_output", + return_value=b"fix: pending\n", + ) + assert get_commits_since_last_tag(["v0.9.0", "v1.0.0"]) == ["fix: pending"] + mock_output.assert_called_once_with(["git", "log", "v1.0.0..HEAD", "--pretty=format:%s"]) + + +def test_get_commits_since_last_tag_failures_return_empty(mocker): + """Git failures return an empty list on both paths.""" + mocker.patch( + "generate_changelog.main.subprocess.check_output", + side_effect=subprocess.CalledProcessError(1, "git"), + ) + assert get_commits_since_last_tag([]) == [] + assert get_commits_since_last_tag(["v1.0.0"]) == [] + + +def test_get_all_commits_chains_tag_ranges(mocker): + """Each tag collects the commits since the previous one, keyed without 'v'.""" + mock_between = mocker.patch( + "generate_changelog.main.get_commits_between_tags", + side_effect=[["feat: a"], ["fix: b"]], + ) + commits_dict = get_all_commits(["v1.0.0", "v1.1.0"]) + assert list(commits_dict.keys()) == ["1.0.0", "1.1.0"] + assert commits_dict["1.0.0"] == ["feat: a"] + assert commits_dict["1.1.0"] == ["fix: b"] + assert mock_between.call_args_list[0].args == ("", "v1.0.0") + assert mock_between.call_args_list[1].args == ("v1.0.0", "v1.1.0") + + +def test_get_tag_dates_includes_unreleased(mocker): + """Tag dates are mapped without the 'v' prefix plus an Unreleased entry.""" + mocker.patch("generate_changelog.main.get_tag_date", return_value="2026-01-01") + dates = get_tag_dates(["v1.0.0"]) + assert dates["1.0.0"] == "2026-01-01" + assert dates["Unreleased"] == TODAY_UTC + + +def test_update_changelog_creates_missing_file(tmp_path, monkeypatch): + """A missing CHANGELOG.md is created with the new content.""" + monkeypatch.chdir(tmp_path) + assert update_changelog("## [1.0.0] - 2026-01-01\n") is True + assert (tmp_path / "CHANGELOG.md").read_text(encoding="utf-8") == "## [1.0.0] - 2026-01-01\n" + + +def test_update_changelog_skips_identical_content(tmp_path, monkeypatch): + """Whitespace-only differences do not rewrite the changelog.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "CHANGELOG.md").write_text("## [1.0.0] - 2026-01-01\n\n- a\n", encoding="utf-8") + assert update_changelog("## [1.0.0] - 2026-01-01\n- a") is False + + +def test_update_changelog_overwrites_on_changes(tmp_path, monkeypatch): + """Real content changes overwrite the existing changelog.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "CHANGELOG.md").write_text("## [1.0.0] - 2026-01-01\n", encoding="utf-8") + assert update_changelog("## [1.1.0] - 2026-02-01\n") is True + assert "1.1.0" in (tmp_path / "CHANGELOG.md").read_text(encoding="utf-8") + + +def test_update_changelog_read_error_returns_false(tmp_path, monkeypatch): + """An unreadable CHANGELOG.md aborts the update returning False.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "CHANGELOG.md").mkdir() # a directory cannot be read as a file + assert update_changelog("content") is False + + +def test_update_changelog_write_error_returns_false(tmp_path, monkeypatch): + """An unwritable CHANGELOG.md aborts the update returning False.""" + monkeypatch.chdir(tmp_path) + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text("old content\n", encoding="utf-8") + changelog.chmod(0o444) + try: + assert update_changelog("new content\n") is False + finally: + changelog.chmod(0o644) + + +def test_main_generates_and_updates(mocker): + """main() builds the changelog from bump-version history and hands it to update_changelog.""" + mocker.patch("generate_changelog.main.fetch_tags") + mocker.patch( + "generate_changelog.main.get_version_history", + return_value=[{"version": "1.0.0", "commit": "abc123", "date": "2026-01-01"}], + ) + mocker.patch("generate_changelog.main.get_commits_between_tags", return_value=["feat: first"]) + mock_update = mocker.patch("generate_changelog.main.update_changelog", return_value=True) + + gcl.main() + + mock_update.assert_called_once() + content = mock_update.call_args.args[0] + assert "## [1.0.0] - 2026-01-01" in content + assert "- first" in content + + +def test_main_without_commits_does_not_update(mocker, caplog): + """main() skips the update when there is nothing to include.""" + mocker.patch("generate_changelog.main.fetch_tags") + mocker.patch( + "generate_changelog.main.get_version_history", + return_value=[{"version": "1.0.0", "commit": "abc123", "date": "2026-01-01"}], + ) + mocker.patch("generate_changelog.main.get_commits_between_tags", return_value=[]) + mock_update = mocker.patch("generate_changelog.main.update_changelog") + + with caplog.at_level(logging.INFO, logger="generate_changelog.main"): + gcl.main() + + mock_update.assert_not_called() + assert "No commits found" in caplog.text + + +def test_main_logs_when_no_update_needed(mocker, caplog): + """main() reports when the changelog content did not change.""" + mocker.patch("generate_changelog.main.fetch_tags") + mocker.patch( + "generate_changelog.main.get_version_history", + return_value=[{"version": "1.0.0", "commit": "abc123", "date": "2026-01-01"}], + ) + mocker.patch("generate_changelog.main.get_commits_between_tags", return_value=["feat: first"]) + mocker.patch("generate_changelog.main.update_changelog", return_value=False) + + with caplog.at_level(logging.INFO, logger="generate_changelog.main"): + gcl.main() + + assert "Changelog was not updated" in caplog.text + + +def test_main_falls_back_to_tags_without_bump_commits(mocker): + """Without bump-version commits, main() falls back to the legacy tag-based path.""" + mocker.patch("generate_changelog.main.fetch_tags") + mocker.patch("generate_changelog.main.get_version_history", return_value=[]) + mocker.patch("generate_changelog.main.get_sorted_tags", return_value=["v1.0.0"]) + mocker.patch( + "generate_changelog.main.get_tag_dates", + return_value={"1.0.0": "2026-01-01", "Unreleased": TODAY_UTC}, + ) + mocker.patch( + "generate_changelog.main.get_all_commits", + return_value=OrderedDict([("1.0.0", ["feat: first"])]), + ) + mock_update = mocker.patch("generate_changelog.main.update_changelog", return_value=True) + + gcl.main() + + content = mock_update.call_args.args[0] + assert "## [1.0.0] - 2026-01-01" in content + + +def test_normalize_version_handles_v_prefix_and_suffix(): + """normalize_version tolerates an optional 'v' and pre-release suffixes.""" + assert gcl.normalize_version("v1.2.8-development") == "1.2.8" + assert gcl.normalize_version("1.2.8") == "1.2.8" + assert gcl.normalize_version("V1.2.8") == "1.2.8" + assert gcl.normalize_version(" v2.0.0 ") == "2.0.0" + + +def test_get_version_history_parses_bump_commits(mocker): + """Versions come from 'Bump version: X -> Y'; CI 'into' promotions are ignored.""" + log = ( + "h1\x1f2026-01-01 00:00:00 +0000\x1fBump version: 1.0.0 → 1.0.1\n" + "h2\x1f2026-01-02 00:00:00 +0000\x1ffeat: work\n" + "h3\x1f2026-01-03 00:00:00 +0000\x1fBump version: v1.0.1 -> v1.0.2\n" + "h4\x1f2026-01-04 00:00:00 +0000\x1fFrom development → Bump version: " + "v1.0.3-development into testing\n" + ) + mocker.patch("generate_changelog.main.subprocess.check_output", return_value=log) + history = gcl.get_version_history() + assert [entry["version"] for entry in history] == ["1.0.1", "1.0.2"] + assert history[0]["commit"] == "h1" + assert history[0]["date"] == "2026-01-01" + + +def test_build_from_history_builds_ranges_and_dates(mocker): + """build_from_history maps each version to its commit range and date.""" + mocker.patch( + "generate_changelog.main.get_commits_between_tags", + side_effect=[["feat: a"], ["fix: b"]], + ) + history = [ + {"version": "1.0.0", "commit": "c1", "date": "2026-01-01"}, + {"version": "1.0.1", "commit": "c2", "date": "2026-01-02"}, + ] + commits_dict, dates = gcl.build_from_history(history) + assert list(commits_dict.keys()) == ["1.0.0", "1.0.1"] + assert commits_dict["1.0.0"] == ["feat: a"] + assert dates["1.0.1"] == "2026-01-02" + + +def test_generate_full_changelog_skips_noise_only_versions(): + """A version whose range is all noise produces no (empty) entry.""" + commits_dict = OrderedDict( + [("1.0.0", ["feat: real"]), ("1.0.1", ["Bump version: 1.0.0 -> 1.0.1"])] + ) + dates = {"1.0.0": "2026-01-01", "1.0.1": "2026-01-02"} + content = gcl.generate_full_changelog(commits_dict, dates) + assert "## [1.0.0]" in content + assert "## [1.0.1]" not in content diff --git a/tests/test_init_security_config.py b/tests/test_init_security_config.py new file mode 100644 index 0000000..0844f68 --- /dev/null +++ b/tests/test_init_security_config.py @@ -0,0 +1,195 @@ +# tests/test_init_security_config.py + +import logging +import string +import sys + +import pytest + +import init_security_config.main as isc +from init_security_config.main import ArgumentParser, LoggerConfigurator, VariableReplacer + +ALNUM = string.ascii_letters + string.digits +SPECIALS = ALNUM + "!#%*-_=+;,." + + +@pytest.fixture +def replacer(): + """VariableReplacer wired to a quiet test logger.""" + return VariableReplacer(logging.getLogger("test_init_security_config")) + + +def test_generate_random_string_chars(replacer): + """'Chars' produces alphanumeric strings of the requested length.""" + value = replacer.generate_random_string(16, "Chars") + assert len(value) == 16 + assert all(char in ALNUM for char in value) + + +def test_generate_random_string_with_specials(replacer): + """'Chars-with-specials' draws from the extended character set.""" + value = replacer.generate_random_string(24, "Chars-with-specials") + assert len(value) == 24 + assert all(char in SPECIALS for char in value) + + +def test_generate_random_string_unknown_type_defaults_to_chars(replacer): + """An unknown chars type falls back to alphanumeric.""" + value = replacer.generate_random_string(8, "Unknown") + assert len(value) == 8 + assert all(char in ALNUM for char in value) + + +def test_replace_placeholders_random(replacer): + """ placeholders are replaced by generated strings.""" + line = replacer.replace_placeholders("TOKEN=<8 (Chars)>") + assert "<" not in line + assert len(line) == len("TOKEN=") + 8 + + +def test_replace_placeholders_defined_variable(replacer): + """ placeholders resolve against collected variables.""" + replacer.variables["FOO"] = "bar" + assert replacer.replace_placeholders("value: ") == "value: bar" + + +def test_replace_placeholders_undefined_variable_left_as_is(replacer): + """Undefined placeholders stay untouched.""" + assert replacer.replace_placeholders("value: ") == "value: " + + +def test_remove_inline_comments_keeps_full_line_comments(replacer): + """Full-line comments are preserved.""" + assert replacer.remove_inline_comments("# full comment") == "# full comment" + + +def test_remove_inline_comments_strips_trailing_comment(replacer): + """Inline comments after values are removed.""" + assert replacer.remove_inline_comments("KEY=value # note") == "KEY=value" + + +def test_remove_inline_comments_respects_quotes(replacer): + """Hashes inside quotes are not treated as comments.""" + assert replacer.remove_inline_comments("KEY='a # b'") == "KEY='a # b'" + assert replacer.remove_inline_comments('KEY="a # b" # real') == 'KEY="a # b"' + + +def test_clean_spaces(replacer): + """Surrounding and repeated whitespace is collapsed.""" + assert replacer.clean_spaces(" a b ") == "a b" + + +def test_collect_variables_quoted_definition(replacer): + """A quoted variable definition is collected and reconstructed.""" + line, is_def = replacer.collect_variables('TOKEN="abc"') + assert is_def is True + assert line == 'TOKEN="abc"' + assert replacer.variables["TOKEN"] == "abc" + + +def test_collect_variables_unquoted_definition(replacer): + """An unquoted variable definition is collected.""" + line, is_def = replacer.collect_variables("TOKEN=abc") + assert is_def is True + assert line == "TOKEN=abc" + assert replacer.variables["TOKEN"] == "abc" + + +def test_collect_variables_resolves_placeholders_in_value(replacer): + """Placeholders inside the defined value are resolved before storing.""" + line, is_def = replacer.collect_variables('SECRET="<6 (Chars)>"') + assert is_def is True + assert len(replacer.variables["SECRET"]) == 6 + assert line == f'SECRET="{replacer.variables["SECRET"]}"' + + +def test_collect_variables_ignores_non_definitions(replacer): + """Lines that are not variable definitions pass through.""" + line, is_def = replacer.collect_variables("just some text") + assert is_def is False + assert line == "just some text" + + +def test_process_file_end_to_end(replacer, tmp_path): + """A template file is fully resolved: comments, placeholders and blank lines.""" + env_file = tmp_path / ".env.example" + env_file.write_text( + "# Database settings\n" + 'TOKEN="<8 (Chars)>"\n' + "URL=https://example.com # endpoint\n" + "REF=\n" + "\n", + encoding="utf-8", + ) + replacer.process_file(str(env_file)) + lines = env_file.read_text(encoding="utf-8").splitlines() + + assert lines[0] == "# Database settings" + token_value = lines[1].split("=", 1)[1].strip('"') + assert len(token_value) == 8 + assert lines[2] == "URL=https://example.com" + assert lines[3] == f"REF={token_value}" + assert len(lines) == 4 # the blank line was dropped + + +def test_process_file_missing_file_logs_error(replacer, tmp_path, caplog): + """A nonexistent file logs an error and does not raise.""" + with caplog.at_level(logging.ERROR, logger="test_init_security_config"): + replacer.process_file(str(tmp_path / "missing.env")) + assert "does not exist" in caplog.text + + +def test_process_file_read_error_logs_error(replacer, tmp_path, caplog): + """An unreadable file logs a read failure and does not raise.""" + env_file = tmp_path / ".env.example" + env_file.write_text("KEY=value\n", encoding="utf-8") + env_file.chmod(0o000) + try: + with caplog.at_level(logging.ERROR, logger="test_init_security_config"): + replacer.process_file(str(env_file)) + finally: + env_file.chmod(0o644) + assert "Failed to read" in caplog.text + + +def test_logger_configurator_invalid_level_raises(tmp_path, monkeypatch): + """An unknown log level raises ValueError.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="Invalid log level"): + LoggerConfigurator(log_level="NOISY") + + +def test_logger_configurator_sets_handlers(tmp_path, monkeypatch): + """A valid level configures file and console handlers.""" + monkeypatch.chdir(tmp_path) + configurator = LoggerConfigurator(log_level="DEBUG") + assert len(configurator.logger.handlers) == 2 + + +def test_argument_parser_parses_files(monkeypatch): + """--files collects every path passed.""" + monkeypatch.setattr(sys, "argv", ["init_security_config", "--files", "a.env", "b.ini"]) + args = ArgumentParser().parse() + assert args.files == ["a.env", "b.ini"] + assert args.log_level == "INFO" + + +def test_argument_parser_requires_files(monkeypatch): + """Missing --files makes argparse exit with code 2.""" + monkeypatch.setattr(sys, "argv", ["init_security_config"]) + with pytest.raises(SystemExit) as exc_info: + ArgumentParser().parse() + assert exc_info.value.code == 2 + + +def test_main_processes_files(tmp_path, monkeypatch): + """main() resolves placeholders of every file passed via --files.""" + monkeypatch.chdir(tmp_path) + env_file = tmp_path / ".env.example" + env_file.write_text('SECRET="<10 (Chars)>"\n', encoding="utf-8") + monkeypatch.setattr(sys, "argv", ["init_security_config", "--files", str(env_file)]) + isc.main() + content = env_file.read_text(encoding="utf-8") + assert "<10 (Chars)>" not in content + secret_value = content.strip().split("=", 1)[1].strip('"') + assert len(secret_value) == 10 diff --git a/tests/test_validate_container_names.py b/tests/test_validate_container_names.py new file mode 100644 index 0000000..5084867 --- /dev/null +++ b/tests/test_validate_container_names.py @@ -0,0 +1,83 @@ +# tests/test_validate_container_names.py + +import sys + +import pytest + +from validate_container_names.main import validate_filename, main + + +@pytest.mark.parametrize( + "filename", + [ + "ContainerGESTIONATENCIONBACKPROD.yml", + "ContainerABPROD.yml", + "ContainerAB12TEST.yml", + "ContainerX9DEV.yml", + ], +) +def test_validate_filename_valid(filename, capsys): + """Valid container template names pass validation and print an [OK] line.""" + assert validate_filename(filename) is True + captured = capsys.readouterr() + assert "[OK]" in captured.out + + +@pytest.mark.parametrize( + "filename", + [ + "Containerab12PROD.yml", # lowercase suite/app + "ContainerABCD.yml", # missing env suffix + "ContainerAPROD.yml", # suite+app shorter than 2 chars + "ContainerAB-CDPROD.yml", # hyphen not allowed + "ContainerABPROD.yaml", # wrong extension + "containerABPROD.yml", # lowercase prefix + "ABPROD.yml", # missing Container prefix + ], +) +def test_validate_filename_invalid(filename, capsys): + """Invalid names fail validation and print the [ERROR] explanation.""" + assert validate_filename(filename) is False + captured = capsys.readouterr() + assert "[ERROR]" in captured.out + assert "does not follow the naming convention" in captured.out + + +def test_validate_filename_uses_basename(capsys): + """Validation applies to the basename, ignoring directories in the path.""" + assert validate_filename("templates/clients/ContainerABTEST.yml") is True + captured = capsys.readouterr() + assert "suite+app: 'AB'" in captured.out + assert "env: 'TEST'" in captured.out + + +def test_main_without_arguments_exits_1(monkeypatch, capsys): + """main() exits with code 1 when no files are passed.""" + monkeypatch.setattr(sys, "argv", ["validate_container_names"]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "Incorrect usage" in capsys.readouterr().out + + +def test_main_all_valid_exits_0(monkeypatch, capsys): + """main() exits with code 0 when every file passes validation.""" + monkeypatch.setattr( + sys, "argv", ["validate_container_names", "ContainerABPROD.yml", "ContainerCDTEST.yml"] + ) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + assert "All 2 file(s) passed naming validation" in capsys.readouterr().out + + +def test_main_with_invalid_file_exits_1(monkeypatch, capsys): + """main() exits with code 1 and lists the offending files.""" + monkeypatch.setattr( + sys, "argv", ["validate_container_names", "ContainerABPROD.yml", "badname.yml"] + ) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "[FAIL] 1 file(s) with invalid names: badname.yml" in captured.out diff --git a/tests/test_validate_docker_compose.py b/tests/test_validate_docker_compose.py new file mode 100644 index 0000000..c42e1a0 --- /dev/null +++ b/tests/test_validate_docker_compose.py @@ -0,0 +1,105 @@ +# tests/test_validate_docker_compose.py + +import subprocess +import sys + +import pytest + +from validate_docker_compose.main import format_docker_compose, validate_docker_compose, main + + +def _write(tmp_path, name, content): + file_path = tmp_path / name + file_path.write_text(content, encoding="utf-8") + return str(file_path) + + +def test_format_adds_document_start_marker(tmp_path, capsys): + """A compose file without '---' gets the marker prepended.""" + file_path = _write(tmp_path, "docker-compose.yml", "services:\n app:\n image: x\n") + format_docker_compose(file_path) + content = (tmp_path / "docker-compose.yml").read_text(encoding="utf-8") + assert content.startswith("---\n") + captured = capsys.readouterr() + assert "Added '---' at the beginning" in captured.out + + +def test_format_keeps_existing_marker(tmp_path, capsys): + """A compose file that already starts with '---' is left as is.""" + file_path = _write(tmp_path, "docker-compose.yml", "---\nservices: {}\n") + format_docker_compose(file_path) + content = (tmp_path / "docker-compose.yml").read_text(encoding="utf-8") + assert content == "---\nservices: {}\n" + assert "Added '---'" not in capsys.readouterr().out + + +def test_format_normalizes_line_endings_to_lf(tmp_path): + """CRLF line endings are rewritten as LF.""" + file_path = tmp_path / "docker-compose.yml" + file_path.write_bytes(b"---\r\nservices: {}\r\n") + format_docker_compose(str(file_path)) + assert b"\r" not in file_path.read_bytes() + + +def test_validate_success(mocker, capsys): + """A valid compose file prints [OK].""" + mock_run = mocker.patch("validate_docker_compose.main.subprocess.run") + validate_docker_compose("docker-compose.yml") + mock_run.assert_called_once() + assert "[OK] docker-compose.yml is valid." in capsys.readouterr().out + + +def test_validate_invalid_compose_exits_1(mocker, capsys): + """docker compose config failure prints stderr and exits with 1.""" + mocker.patch( + "validate_docker_compose.main.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "docker", stderr="invalid service"), + ) + with pytest.raises(SystemExit) as exc_info: + validate_docker_compose("docker-compose.yml") + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "[ERROR] Error in file docker-compose.yml:" in captured.out + assert "invalid service" in captured.out + + +def test_validate_docker_not_installed_exits_1(mocker, capsys): + """Missing docker binary exits with 1.""" + mocker.patch("validate_docker_compose.main.subprocess.run", side_effect=FileNotFoundError) + with pytest.raises(SystemExit) as exc_info: + validate_docker_compose("docker-compose.yml") + assert exc_info.value.code == 1 + assert "docker compose not found" in capsys.readouterr().out + + +def test_main_without_arguments_exits_1(monkeypatch, capsys): + """main() exits with code 1 when no files are passed.""" + monkeypatch.setattr(sys, "argv", ["validate_docker_compose"]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "Incorrect usage" in capsys.readouterr().out + + +def test_main_missing_file_exits_1(monkeypatch, tmp_path, capsys): + """main() exits with code 1 when a file does not exist.""" + missing = str(tmp_path / "nope.yml") + monkeypatch.setattr(sys, "argv", ["validate_docker_compose", missing]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + assert "does not exist" in capsys.readouterr().out + + +def test_main_formats_and_validates_all_files(monkeypatch, tmp_path, mocker, capsys): + """main() formats and validates every file, then exits 0.""" + mocker.patch("validate_docker_compose.main.subprocess.run") + file_a = _write(tmp_path, "a.yml", "services: {}\n") + file_b = _write(tmp_path, "b.yml", "---\nservices: {}\n") + monkeypatch.setattr(sys, "argv", ["validate_docker_compose", file_a, file_b]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "All checks passed successfully." in captured.out + assert (tmp_path / "a.yml").read_text(encoding="utf-8").startswith("---\n")