mirror of
https://github.com/esphome/esphome.git
synced 2026-09-24 05:24:14 +00:00
[core] Add cv.sensitive marker for schema-level sensitive fields (#16673)
This commit is contained in:
@@ -3,8 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from esphome import config_validation as cv
|
||||
|
||||
SCRIPT_PATH = (
|
||||
Path(__file__).resolve().parent.parent.parent
|
||||
/ "script"
|
||||
@@ -12,10 +15,16 @@ SCRIPT_PATH = (
|
||||
)
|
||||
|
||||
|
||||
def _load_script_module():
|
||||
spec = importlib.util.spec_from_file_location("build_language_schema", SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _extract_sort_obj():
|
||||
# build_language_schema.py runs argparse, loads every component, and
|
||||
# calls build_schema() at import time, so a plain import isn't viable
|
||||
# in a unit test. Pull just the pure helper out via AST instead.
|
||||
# ``sort_obj`` is pure and self-contained; pulling it via AST avoids
|
||||
# exercising the module-level component-loading state for these tests.
|
||||
tree = ast.parse(SCRIPT_PATH.read_text())
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "sort_obj":
|
||||
@@ -27,6 +36,7 @@ def _extract_sort_obj():
|
||||
|
||||
|
||||
sort_obj = _extract_sort_obj()
|
||||
_bls = _load_script_module()
|
||||
|
||||
|
||||
def test_sort_obj_sorts_dict_keys() -> None:
|
||||
@@ -96,3 +106,56 @@ def test_sort_obj_passes_through_scalars() -> None:
|
||||
assert sort_obj(42) == 42
|
||||
assert sort_obj(None) is None
|
||||
assert sort_obj(True) is True
|
||||
|
||||
|
||||
def test_convert_emits_explicit_sensitive_marker() -> None:
|
||||
config_var: dict = {}
|
||||
_bls.convert(cv.sensitive(cv.string), config_var, "/test")
|
||||
|
||||
assert config_var["sensitive"] is True
|
||||
assert config_var["sensitive_source"] == "explicit"
|
||||
assert config_var["type"] == "string"
|
||||
|
||||
|
||||
def test_convert_keys_emits_heuristic_sensitive_marker() -> None:
|
||||
converted: dict = {}
|
||||
_bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root")
|
||||
|
||||
entry = converted["schema"]["config_vars"]["password"]
|
||||
assert entry["sensitive"] is True
|
||||
assert entry["sensitive_source"] == "heuristic"
|
||||
assert entry["type"] == "string"
|
||||
|
||||
|
||||
def test_convert_keys_explicit_beats_heuristic() -> None:
|
||||
# Key name matches a fragment but the validator is explicitly wrapped;
|
||||
# the explicit branch should win and emit ``sensitive_source: explicit``.
|
||||
converted: dict = {}
|
||||
_bls.convert_keys(
|
||||
converted, {cv.Optional("password"): cv.sensitive(cv.string)}, "/root"
|
||||
)
|
||||
|
||||
entry = converted["schema"]["config_vars"]["password"]
|
||||
assert entry["sensitive"] is True
|
||||
assert entry["sensitive_source"] == "explicit"
|
||||
|
||||
|
||||
def test_convert_keys_no_heuristic_for_non_string_leaves() -> None:
|
||||
# Even though the key contains a fragment, a non-string leaf must not
|
||||
# be flagged. Prevents false positives on unrelated fields whose name
|
||||
# happens to embed a substring like "token".
|
||||
converted: dict = {}
|
||||
_bls.convert_keys(converted, {cv.Optional("password"): cv.boolean}, "/root")
|
||||
|
||||
entry = converted["schema"]["config_vars"]["password"]
|
||||
assert "sensitive" not in entry
|
||||
assert "sensitive_source" not in entry
|
||||
|
||||
|
||||
def test_convert_keys_no_marker_for_non_sensitive_field() -> None:
|
||||
converted: dict = {}
|
||||
_bls.convert_keys(converted, {cv.Optional("hostname"): cv.string}, "/root")
|
||||
|
||||
entry = converted["schema"]["config_vars"]["hostname"]
|
||||
assert "sensitive" not in entry
|
||||
assert "sensitive_source" not in entry
|
||||
|
||||
@@ -127,6 +127,49 @@ def test_string_string__invalid(value):
|
||||
config_validation.string_strict(value)
|
||||
|
||||
|
||||
def test_sensitive__default_delegates_to_string() -> None:
|
||||
validator = config_validation.sensitive()
|
||||
|
||||
assert isinstance(validator, config_validation.SensitiveValidator)
|
||||
assert validator.inner is config_validation.string
|
||||
assert validator("hunter2") == "hunter2"
|
||||
assert validator(42) == "42"
|
||||
|
||||
|
||||
def test_sensitive__custom_inner_delegates_validation() -> None:
|
||||
validator = config_validation.sensitive(config_validation.string_strict)
|
||||
|
||||
assert validator.inner is config_validation.string_strict
|
||||
assert validator("abc") == "abc"
|
||||
with pytest.raises(Invalid, match="Must be string, got"):
|
||||
validator(123)
|
||||
|
||||
|
||||
def test_sensitive__is_detectable_via_isinstance() -> None:
|
||||
validator = config_validation.sensitive()
|
||||
|
||||
assert isinstance(validator, config_validation.SensitiveValidator)
|
||||
|
||||
|
||||
def test_sensitive__repr_mirrors_inner() -> None:
|
||||
# The schema dump dedups on ``repr(schema)``; mirroring the inner
|
||||
# validator's repr keeps two ``cv.sensitive(cv.string)`` wrappers
|
||||
# interchangeable for that purpose and avoids leaking the wrapper as
|
||||
# noise in voluptuous error messages.
|
||||
assert repr(config_validation.sensitive(config_validation.string)) == repr(
|
||||
config_validation.string
|
||||
)
|
||||
assert repr(config_validation.sensitive(config_validation.string)) == repr(
|
||||
config_validation.sensitive(config_validation.string)
|
||||
)
|
||||
|
||||
|
||||
def test_sensitive_key_fragments__covers_common_terms() -> None:
|
||||
assert isinstance(config_validation.SENSITIVE_KEY_FRAGMENTS, frozenset)
|
||||
for term in ("password", "passcode", "secret", "token", "api_key", "apikey", "psk"):
|
||||
assert term in config_validation.SENSITIVE_KEY_FRAGMENTS
|
||||
|
||||
|
||||
@given(
|
||||
builds(
|
||||
lambda v: "mdi:" + v,
|
||||
|
||||
Reference in New Issue
Block a user