From 35ff3afa124b1afa3eda17efedc44d8111011c3f Mon Sep 17 00:00:00 2001 From: Jan Morawiec Date: Thu, 17 Oct 2024 20:28:14 +0100 Subject: [PATCH] Refactor unittest tests to use pytest (#127770) * Refactor unittest tests to use pytest * Add type annotations * Use caplog to assert logs --------- Co-authored-by: Martin Hjelmare --- tests/util/yaml/test_init.py | 142 +----------------------- tests/util/yaml/test_secrets.py | 185 ++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 141 deletions(-) create mode 100644 tests/util/yaml/test_secrets.py diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index dbd7f1d2e99..8db3f49ab8e 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -6,7 +6,6 @@ import io import os import pathlib from typing import Any -import unittest from unittest.mock import Mock, patch import pytest @@ -19,7 +18,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import yaml from homeassistant.util.yaml import loader as yaml_loader -from tests.common import extract_stack_to_frame, get_test_config_dir, patch_yaml_files +from tests.common import extract_stack_to_frame @pytest.fixture(params=["enable_c_loader", "disable_c_loader"]) @@ -396,145 +395,6 @@ def test_dump_unicode() -> None: assert yaml.dump({"a": None, "b": "привет"}) == "a:\nb: привет\n" -FILES = {} - - -def load_yaml(fname, string, secrets=None): - """Write a string to file and return the parsed yaml.""" - FILES[fname] = string - with patch_yaml_files(FILES): - return load_yaml_config_file(fname, secrets) - - -class TestSecrets(unittest.TestCase): - """Test the secrets parameter in the yaml utility.""" - - def setUp(self): - """Create & load secrets file.""" - config_dir = get_test_config_dir() - self._yaml_path = os.path.join(config_dir, YAML_CONFIG_FILE) - self._secret_path = os.path.join(config_dir, yaml.SECRET_YAML) - self._sub_folder_path = os.path.join(config_dir, "subFolder") - self._unrelated_path = os.path.join(config_dir, "unrelated") - - load_yaml( - self._secret_path, - ( - "http_pw: pwhttp\n" - "comp1_un: un1\n" - "comp1_pw: pw1\n" - "stale_pw: not_used\n" - "logger: debug\n" - ), - ) - self._yaml = load_yaml( - self._yaml_path, - ( - "http:\n" - " api_password: !secret http_pw\n" - "component:\n" - " username: !secret comp1_un\n" - " password: !secret comp1_pw\n" - "" - ), - yaml_loader.Secrets(config_dir), - ) - - def tearDown(self): - """Clean up secrets.""" - FILES.clear() - - def test_secrets_from_yaml(self): - """Did secrets load ok.""" - expected = {"api_password": "pwhttp"} - assert expected == self._yaml["http"] - - expected = {"username": "un1", "password": "pw1"} - assert expected == self._yaml["component"] - - def test_secrets_from_parent_folder(self): - """Test loading secrets from parent folder.""" - expected = {"api_password": "pwhttp"} - self._yaml = load_yaml( - os.path.join(self._sub_folder_path, "sub.yaml"), - ( - "http:\n" - " api_password: !secret http_pw\n" - "component:\n" - " username: !secret comp1_un\n" - " password: !secret comp1_pw\n" - "" - ), - yaml_loader.Secrets(get_test_config_dir()), - ) - - assert expected == self._yaml["http"] - - def test_secret_overrides_parent(self): - """Test loading current directory secret overrides the parent.""" - expected = {"api_password": "override"} - load_yaml( - os.path.join(self._sub_folder_path, yaml.SECRET_YAML), "http_pw: override" - ) - self._yaml = load_yaml( - os.path.join(self._sub_folder_path, "sub.yaml"), - ( - "http:\n" - " api_password: !secret http_pw\n" - "component:\n" - " username: !secret comp1_un\n" - " password: !secret comp1_pw\n" - "" - ), - yaml_loader.Secrets(get_test_config_dir()), - ) - - assert expected == self._yaml["http"] - - def test_secrets_from_unrelated_fails(self): - """Test loading secrets from unrelated folder fails.""" - load_yaml(os.path.join(self._unrelated_path, yaml.SECRET_YAML), "test: failure") - with pytest.raises(HomeAssistantError): - load_yaml( - os.path.join(self._sub_folder_path, "sub.yaml"), - "http:\n api_password: !secret test", - ) - - def test_secrets_logger_removed(self): - """Ensure logger: debug was removed.""" - with pytest.raises(HomeAssistantError): - load_yaml(self._yaml_path, "api_password: !secret logger") - - @patch("homeassistant.util.yaml.loader._LOGGER.error") - def test_bad_logger_value(self, mock_error): - """Ensure logger: debug was removed.""" - load_yaml(self._secret_path, "logger: info\npw: abc") - load_yaml( - self._yaml_path, - "api_password: !secret pw", - yaml_loader.Secrets(get_test_config_dir()), - ) - assert mock_error.call_count == 1, "Expected an error about logger: value" - - def test_secrets_are_not_dict(self): - """Did secrets handle non-dict file.""" - FILES[self._secret_path] = ( - "- http_pw: pwhttp\n comp1_un: un1\n comp1_pw: pw1\n" - ) - with pytest.raises(HomeAssistantError): - load_yaml( - self._yaml_path, - ( - "http:\n" - " api_password: !secret http_pw\n" - "component:\n" - " username: !secret comp1_un\n" - " password: !secret comp1_pw\n" - "" - ), - ) - - @pytest.mark.parametrize("hass_config_yaml", ['key: [1, "2", 3]']) @pytest.mark.usefixtures("try_both_dumpers", "mock_hass_config_yaml") def test_representing_yaml_loaded_data() -> None: diff --git a/tests/util/yaml/test_secrets.py b/tests/util/yaml/test_secrets.py new file mode 100644 index 00000000000..35b5ae319c4 --- /dev/null +++ b/tests/util/yaml/test_secrets.py @@ -0,0 +1,185 @@ +"""Test Home Assistant secret substitution in YAML files.""" + +from dataclasses import dataclass +import logging +from pathlib import Path + +import pytest + +from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import yaml +from homeassistant.util.yaml import loader as yaml_loader + +from tests.common import get_test_config_dir, patch_yaml_files + + +@dataclass(frozen=True) +class YamlFile: + """Represents a .yaml file used for testing.""" + + path: Path + contents: str + + +def load_config_file(config_file_path: Path, files: list[YamlFile]): + """Patch secret files and return the loaded config file.""" + patch_files = {x.path.as_posix(): x.contents for x in files} + with patch_yaml_files(patch_files): + return load_yaml_config_file( + config_file_path.as_posix(), + yaml_loader.Secrets(Path(get_test_config_dir())), + ) + + +@pytest.fixture +def filepaths() -> dict[str, Path]: + """Return a dictionary of filepaths for testing.""" + config_dir = Path(get_test_config_dir()) + return { + "config": config_dir, + "sub_folder": config_dir / "subFolder", + "unrelated": config_dir / "unrelated", + } + + +@pytest.fixture +def default_config(filepaths: dict[str, Path]) -> YamlFile: + """Return the default config file for testing.""" + return YamlFile( + path=filepaths["config"] / YAML_CONFIG_FILE, + contents=( + "http:\n" + " api_password: !secret http_pw\n" + "component:\n" + " username: !secret comp1_un\n" + " password: !secret comp1_pw\n" + "" + ), + ) + + +@pytest.fixture +def default_secrets(filepaths: dict[str, Path]) -> YamlFile: + """Return the default secrets file for testing.""" + return YamlFile( + path=filepaths["config"] / yaml.SECRET_YAML, + contents=( + "http_pw: pwhttp\n" + "comp1_un: un1\n" + "comp1_pw: pw1\n" + "stale_pw: not_used\n" + "logger: debug\n" + ), + ) + + +def test_secrets_from_yaml(default_config: YamlFile, default_secrets: YamlFile) -> None: + """Did secrets load ok.""" + loaded_file = load_config_file( + default_config.path, [default_config, default_secrets] + ) + expected = {"api_password": "pwhttp"} + assert expected == loaded_file["http"] + + expected = {"username": "un1", "password": "pw1"} + assert expected == loaded_file["component"] + + +def test_secrets_from_parent_folder( + filepaths: dict[str, Path], + default_config: YamlFile, + default_secrets: YamlFile, +) -> None: + """Test loading secrets from parent folder.""" + config_file = YamlFile( + path=filepaths["sub_folder"] / "sub.yaml", + contents=default_config.contents, + ) + loaded_file = load_config_file(config_file.path, [config_file, default_secrets]) + expected = {"api_password": "pwhttp"} + + assert expected == loaded_file["http"] + + +def test_secret_overrides_parent( + filepaths: dict[str, Path], + default_config: YamlFile, + default_secrets: YamlFile, +) -> None: + """Test loading current directory secret overrides the parent.""" + config_file = YamlFile( + path=filepaths["sub_folder"] / "sub.yaml", contents=default_config.contents + ) + sub_secrets = YamlFile( + path=filepaths["sub_folder"] / yaml.SECRET_YAML, contents="http_pw: override" + ) + + loaded_file = load_config_file( + config_file.path, [config_file, default_secrets, sub_secrets] + ) + + expected = {"api_password": "override"} + assert loaded_file["http"] == expected + + +def test_secrets_from_unrelated_fails( + filepaths: dict[str, Path], + default_secrets: YamlFile, +) -> None: + """Test loading secrets from unrelated folder fails.""" + config_file = YamlFile( + path=filepaths["sub_folder"] / "sub.yaml", + contents="http:\n api_password: !secret test", + ) + unrelated_secrets = YamlFile( + path=filepaths["unrelated"] / yaml.SECRET_YAML, contents="test: failure" + ) + with pytest.raises(HomeAssistantError, match="Secret test not defined"): + load_config_file( + config_file.path, [config_file, default_secrets, unrelated_secrets] + ) + + +def test_secrets_logger_removed( + filepaths: dict[str, Path], + default_secrets: YamlFile, +) -> None: + """Ensure logger: debug gets removed from secrets file once logger is configured.""" + config_file = YamlFile( + path=filepaths["config"] / YAML_CONFIG_FILE, + contents="api_password: !secret logger", + ) + with pytest.raises(HomeAssistantError, match="Secret logger not defined"): + load_config_file(config_file.path, [config_file, default_secrets]) + + +def test_bad_logger_value( + caplog: pytest.LogCaptureFixture, filepaths: dict[str, Path] +) -> None: + """Ensure only logger: debug is allowed in secret file.""" + config_file = YamlFile( + path=filepaths["config"] / YAML_CONFIG_FILE, contents="api_password: !secret pw" + ) + secrets_file = YamlFile( + path=filepaths["config"] / yaml.SECRET_YAML, contents="logger: info\npw: abc" + ) + with caplog.at_level(logging.ERROR): + load_config_file(config_file.path, [config_file, secrets_file]) + assert ( + "Error in secrets.yaml: 'logger: debug' expected, but 'logger: info' found" + in caplog.messages + ) + + +def test_secrets_are_not_dict( + filepaths: dict[str, Path], + default_config: YamlFile, +) -> None: + """Did secrets handle non-dict file.""" + non_dict_secrets = YamlFile( + path=filepaths["config"] / yaml.SECRET_YAML, + contents="- http_pw: pwhttp\n comp1_un: un1\n comp1_pw: pw1\n", + ) + with pytest.raises(HomeAssistantError, match="Secrets is not a dictionary"): + load_config_file(default_config.path, [default_config, non_dict_secrets])