feat: config file parser + writer utilities with full test coverage (Tasks 4.1, 4.2)
- Fail2BanConfigParser class: merge order, include directives (before/after), variable interpolation %(var)s, split_multiline, ordered_conf_files - config_writer: write_local_override, remove_local_key, delete_local_file with atomic writes (os.replace), per-file threading locks, .local-only guard - 79 tests in tests/test_utils/ (all passing) - mypy --strict: 60 source files, 0 errors - ruff: all checks passed
This commit is contained in:
0
backend/tests/test_utils/__init__.py
Normal file
0
backend/tests/test_utils/__init__.py
Normal file
473
backend/tests/test_utils/test_config_parser.py
Normal file
473
backend/tests/test_utils/test_config_parser.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""Tests for app.utils.config_parser.Fail2BanConfigParser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.utils.config_parser import Fail2BanConfigParser
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures and helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FILTER_CONF = """\
|
||||
[INCLUDES]
|
||||
before = common.conf
|
||||
|
||||
[Definition]
|
||||
failregex = ^%(host)s .*$
|
||||
ignoreregex =
|
||||
"""
|
||||
|
||||
_COMMON_CONF = """\
|
||||
[DEFAULT]
|
||||
host = <HOST>
|
||||
"""
|
||||
|
||||
_FILTER_LOCAL = """\
|
||||
[Definition]
|
||||
failregex = ^OVERRIDE %(host)s$
|
||||
"""
|
||||
|
||||
_ACTION_CONF = """\
|
||||
[Definition]
|
||||
actionstart = iptables -N f2b-<name>
|
||||
actionstop = iptables -X f2b-<name>
|
||||
actionban = iptables -I INPUT -s <ip> -j DROP
|
||||
actionunban = iptables -D INPUT -s <ip> -j DROP
|
||||
|
||||
[Init]
|
||||
name = default
|
||||
ip = 1.2.3.4
|
||||
"""
|
||||
|
||||
|
||||
def _write(tmp_path: Path, name: str, content: str) -> Path:
|
||||
"""Write *content* to *tmp_path/name* and return the path."""
|
||||
p = tmp_path / name
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestOrderedConfFiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOrderedConfFiles:
|
||||
def test_empty_dir_returns_empty(self, tmp_path: Path) -> None:
|
||||
result = Fail2BanConfigParser.ordered_conf_files(tmp_path, "jail")
|
||||
assert result == []
|
||||
|
||||
def test_conf_only(self, tmp_path: Path) -> None:
|
||||
conf = _write(tmp_path, "jail.conf", "[DEFAULT]\n")
|
||||
result = Fail2BanConfigParser.ordered_conf_files(tmp_path, "jail")
|
||||
assert result == [conf]
|
||||
|
||||
def test_conf_then_local(self, tmp_path: Path) -> None:
|
||||
conf = _write(tmp_path, "jail.conf", "[DEFAULT]\n")
|
||||
local = _write(tmp_path, "jail.local", "[DEFAULT]\n")
|
||||
result = Fail2BanConfigParser.ordered_conf_files(tmp_path, "jail")
|
||||
assert result == [conf, local]
|
||||
|
||||
def test_d_dir_overrides_appended(self, tmp_path: Path) -> None:
|
||||
conf = _write(tmp_path, "jail.conf", "[DEFAULT]\n")
|
||||
local = _write(tmp_path, "jail.local", "[DEFAULT]\n")
|
||||
d_dir = tmp_path / "jail.d"
|
||||
d_dir.mkdir()
|
||||
d_conf = _write(d_dir, "extra.conf", "[DEFAULT]\n")
|
||||
d_local = _write(d_dir, "extra.local", "[DEFAULT]\n")
|
||||
result = Fail2BanConfigParser.ordered_conf_files(tmp_path, "jail")
|
||||
assert result == [conf, local, d_conf, d_local]
|
||||
|
||||
def test_missing_local_skipped(self, tmp_path: Path) -> None:
|
||||
conf = _write(tmp_path, "jail.conf", "[DEFAULT]\n")
|
||||
result = Fail2BanConfigParser.ordered_conf_files(tmp_path, "jail")
|
||||
assert conf in result
|
||||
assert len(result) == 1
|
||||
|
||||
def test_d_dir_sorted(self, tmp_path: Path) -> None:
|
||||
d_dir = tmp_path / "jail.d"
|
||||
d_dir.mkdir()
|
||||
_write(d_dir, "zzz.conf", "[DEFAULT]\n")
|
||||
_write(d_dir, "aaa.conf", "[DEFAULT]\n")
|
||||
result = Fail2BanConfigParser.ordered_conf_files(tmp_path, "jail")
|
||||
names = [p.name for p in result]
|
||||
assert names == ["aaa.conf", "zzz.conf"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestReadFile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReadFile:
|
||||
def test_reads_single_file(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path, "common.conf", _COMMON_CONF)
|
||||
p = _write(tmp_path, "filter.conf", _FILTER_CONF)
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
assert parser.has_section("Definition")
|
||||
|
||||
def test_before_include_loaded(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path, "common.conf", _COMMON_CONF)
|
||||
p = _write(tmp_path, "filter.conf", _FILTER_CONF)
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
# DEFAULT from common.conf should be merged.
|
||||
defaults = parser.defaults()
|
||||
assert "host" in defaults
|
||||
|
||||
def test_missing_file_is_silent(self, tmp_path: Path) -> None:
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(tmp_path / "nonexistent.conf")
|
||||
assert parser.sections() == []
|
||||
|
||||
def test_after_include_overrides(self, tmp_path: Path) -> None:
|
||||
after_content = """\
|
||||
[Definition]
|
||||
key = after_value
|
||||
"""
|
||||
after = _write(tmp_path, "after.conf", after_content)
|
||||
_ = after # used via [INCLUDES]
|
||||
main_content = """\
|
||||
[INCLUDES]
|
||||
after = after.conf
|
||||
|
||||
[Definition]
|
||||
key = main_value
|
||||
"""
|
||||
p = _write(tmp_path, "main.conf", main_content)
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
# 'after' was loaded last → highest priority.
|
||||
assert parser.get("Definition", "key") == "after_value"
|
||||
|
||||
def test_cycle_detection(self, tmp_path: Path) -> None:
|
||||
# A includes B, B includes A.
|
||||
a_content = """\
|
||||
[INCLUDES]
|
||||
before = b.conf
|
||||
|
||||
[Definition]
|
||||
key = from_a
|
||||
"""
|
||||
b_content = """\
|
||||
[INCLUDES]
|
||||
before = a.conf
|
||||
|
||||
[Definition]
|
||||
key = from_b
|
||||
"""
|
||||
_write(tmp_path, "a.conf", a_content)
|
||||
_write(tmp_path, "b.conf", b_content)
|
||||
parser = Fail2BanConfigParser()
|
||||
# Should not infinite-loop; terminates via cycle detection.
|
||||
parser.read_file(tmp_path / "a.conf")
|
||||
assert parser.has_section("Definition")
|
||||
|
||||
def test_max_depth_guard(self, tmp_path: Path) -> None:
|
||||
# Create a chain: 0→1→2→…→max+1
|
||||
max_depth = 3
|
||||
for i in range(max_depth + 2):
|
||||
content = f"[INCLUDES]\nbefore = {i + 1}.conf\n\n[s{i}]\nk = v\n"
|
||||
_write(tmp_path, f"{i}.conf", content)
|
||||
parser = Fail2BanConfigParser(max_include_depth=max_depth)
|
||||
parser.read_file(tmp_path / "0.conf")
|
||||
# Should complete without recursion error; some sections will be missing.
|
||||
assert isinstance(parser.sections(), list)
|
||||
|
||||
def test_invalid_ini_is_ignored(self, tmp_path: Path) -> None:
|
||||
bad = _write(tmp_path, "bad.conf", "this is not valid [[[ini\nstuff")
|
||||
parser = Fail2BanConfigParser()
|
||||
# Should not raise; parser logs and continues.
|
||||
parser.read_file(bad)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestReadWithOverrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReadWithOverrides:
|
||||
def test_local_overrides_conf(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path, "sshd.conf", "[Definition]\nfailregex = original\n")
|
||||
_write(tmp_path, "sshd.local", "[Definition]\nfailregex = overridden\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_with_overrides(tmp_path / "sshd.conf")
|
||||
assert parser.get("Definition", "failregex") == "overridden"
|
||||
|
||||
def test_no_local_just_reads_conf(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path, "sshd.conf", "[Definition]\nfailregex = only_conf\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_with_overrides(tmp_path / "sshd.conf")
|
||||
assert parser.get("Definition", "failregex") == "only_conf"
|
||||
|
||||
def test_local_adds_new_key(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path, "sshd.conf", "[Definition]\nfailregex = orig\n")
|
||||
_write(tmp_path, "sshd.local", "[Definition]\nextrakey = newval\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_with_overrides(tmp_path / "sshd.conf")
|
||||
assert parser.get("Definition", "extrakey") == "newval"
|
||||
|
||||
def test_conf_keys_preserved_when_local_overrides_other(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
_write(
|
||||
tmp_path,
|
||||
"sshd.conf",
|
||||
"[Definition]\nfailregex = orig\nignoreregex = keep_me\n",
|
||||
)
|
||||
_write(tmp_path, "sshd.local", "[Definition]\nfailregex = new\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_with_overrides(tmp_path / "sshd.conf")
|
||||
assert parser.get("Definition", "ignoreregex") == "keep_me"
|
||||
assert parser.get("Definition", "failregex") == "new"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSections
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSections:
|
||||
def test_sections_excludes_default(self, tmp_path: Path) -> None:
|
||||
content = "[DEFAULT]\nfoo = bar\n\n[Definition]\nbaz = qux\n"
|
||||
p = _write(tmp_path, "x.conf", content)
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
secs = parser.sections()
|
||||
assert "DEFAULT" not in secs
|
||||
assert "Definition" in secs
|
||||
|
||||
def test_has_section_true(self, tmp_path: Path) -> None:
|
||||
p = _write(tmp_path, "x.conf", "[Init]\nname = test\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
assert parser.has_section("Init") is True
|
||||
|
||||
def test_has_section_false(self, tmp_path: Path) -> None:
|
||||
p = _write(tmp_path, "x.conf", "[Init]\nname = test\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
assert parser.has_section("Nonexistent") is False
|
||||
|
||||
def test_get_returns_none_for_missing_section(self, tmp_path: Path) -> None:
|
||||
p = _write(tmp_path, "x.conf", "[Init]\nname = test\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
assert parser.get("NoSection", "key") is None
|
||||
|
||||
def test_get_returns_none_for_missing_key(self, tmp_path: Path) -> None:
|
||||
p = _write(tmp_path, "x.conf", "[Init]\nname = test\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
assert parser.get("Init", "nokey") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSectionDict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSectionDict:
|
||||
def test_returns_all_keys(self, tmp_path: Path) -> None:
|
||||
p = _write(tmp_path, "x.conf", "[Definition]\na = 1\nb = 2\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
d = parser.section_dict("Definition")
|
||||
assert d == {"a": "1", "b": "2"}
|
||||
|
||||
def test_empty_for_missing_section(self, tmp_path: Path) -> None:
|
||||
p = _write(tmp_path, "x.conf", "[Definition]\na = 1\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
assert parser.section_dict("Init") == {}
|
||||
|
||||
def test_skip_excludes_keys(self, tmp_path: Path) -> None:
|
||||
p = _write(tmp_path, "x.conf", "[Definition]\na = 1\nb = 2\nc = 3\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
d = parser.section_dict("Definition", skip=frozenset({"b"}))
|
||||
assert "b" not in d
|
||||
assert d["a"] == "1"
|
||||
|
||||
def test_dunder_keys_excluded(self, tmp_path: Path) -> None:
|
||||
# configparser can inject __name__, __add__ etc. from DEFAULT.
|
||||
content = "[DEFAULT]\n__name__ = foo\n\n[Definition]\nreal = val\n"
|
||||
p = _write(tmp_path, "x.conf", content)
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
d = parser.section_dict("Definition")
|
||||
assert "__name__" not in d
|
||||
assert "real" in d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestDefaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDefaults:
|
||||
def test_defaults_from_default_section(self, tmp_path: Path) -> None:
|
||||
content = "[DEFAULT]\nhost = <HOST>\n\n[Definition]\nfailregex = ^\n"
|
||||
p = _write(tmp_path, "x.conf", content)
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
assert parser.defaults().get("host") == "<HOST>"
|
||||
|
||||
def test_defaults_empty_when_no_default_section(self, tmp_path: Path) -> None:
|
||||
p = _write(tmp_path, "x.conf", "[Definition]\nfailregex = ^\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
assert parser.defaults() == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestInterpolate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInterpolate:
|
||||
def _parser_with(self, tmp_path: Path, content: str) -> Fail2BanConfigParser:
|
||||
p = _write(tmp_path, "x.conf", content)
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(p)
|
||||
return parser
|
||||
|
||||
def test_substitutes_default_var(self, tmp_path: Path) -> None:
|
||||
parser = self._parser_with(
|
||||
tmp_path,
|
||||
"[DEFAULT]\nhost = <HOST>\n\n[Definition]\nrule = match %(host)s\n",
|
||||
)
|
||||
assert parser.interpolate("match %(host)s") == "match <HOST>"
|
||||
|
||||
def test_substitutes_init_var(self, tmp_path: Path) -> None:
|
||||
parser = self._parser_with(
|
||||
tmp_path,
|
||||
"[Init]\nname = sshd\n",
|
||||
)
|
||||
assert parser.interpolate("f2b-%(name)s") == "f2b-sshd"
|
||||
|
||||
def test_extra_vars_highest_priority(self, tmp_path: Path) -> None:
|
||||
parser = self._parser_with(
|
||||
tmp_path,
|
||||
"[DEFAULT]\nname = default_name\n",
|
||||
)
|
||||
result = parser.interpolate("%(name)s", extra_vars={"name": "override"})
|
||||
assert result == "override"
|
||||
|
||||
def test_unresolvable_left_unchanged(self, tmp_path: Path) -> None:
|
||||
parser = Fail2BanConfigParser()
|
||||
result = parser.interpolate("value %(unknown)s end")
|
||||
assert result == "value %(unknown)s end"
|
||||
|
||||
def test_nested_interpolation(self, tmp_path: Path) -> None:
|
||||
# %(outer)s → %(inner)s → final
|
||||
parser = self._parser_with(
|
||||
tmp_path,
|
||||
"[DEFAULT]\ninner = final\nouter = %(inner)s\n",
|
||||
)
|
||||
assert parser.interpolate("%(outer)s") == "final"
|
||||
|
||||
def test_no_references_returned_unchanged(self, tmp_path: Path) -> None:
|
||||
parser = Fail2BanConfigParser()
|
||||
assert parser.interpolate("plain value") == "plain value"
|
||||
|
||||
def test_empty_string(self, tmp_path: Path) -> None:
|
||||
parser = Fail2BanConfigParser()
|
||||
assert parser.interpolate("") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSplitMultiline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSplitMultiline:
|
||||
def test_strips_blank_lines(self) -> None:
|
||||
raw = "line1\n\nline2\n\n"
|
||||
assert Fail2BanConfigParser.split_multiline(raw) == ["line1", "line2"]
|
||||
|
||||
def test_strips_comment_lines(self) -> None:
|
||||
raw = "line1\n# comment\nline2"
|
||||
assert Fail2BanConfigParser.split_multiline(raw) == ["line1", "line2"]
|
||||
|
||||
def test_strips_leading_whitespace(self) -> None:
|
||||
raw = " line1\n line2"
|
||||
assert Fail2BanConfigParser.split_multiline(raw) == ["line1", "line2"]
|
||||
|
||||
def test_empty_input(self) -> None:
|
||||
assert Fail2BanConfigParser.split_multiline("") == []
|
||||
|
||||
def test_all_comments(self) -> None:
|
||||
raw = "# first\n# second"
|
||||
assert Fail2BanConfigParser.split_multiline(raw) == []
|
||||
|
||||
def test_single_line(self) -> None:
|
||||
assert Fail2BanConfigParser.split_multiline("single") == ["single"]
|
||||
|
||||
def test_preserves_internal_spaces(self) -> None:
|
||||
raw = "iptables -I INPUT -s <ip> -j DROP"
|
||||
assert Fail2BanConfigParser.split_multiline(raw) == [
|
||||
"iptables -I INPUT -s <ip> -j DROP"
|
||||
]
|
||||
|
||||
def test_multiline_regex_list(self) -> None:
|
||||
raw = (
|
||||
"\n"
|
||||
" ^%(__prefix_line)s Authentication failure for .* from <HOST>\n"
|
||||
" # inline comment, skip\n"
|
||||
" ^%(__prefix_line)s BREAK-IN ATTEMPT by <HOST>\n"
|
||||
)
|
||||
result = Fail2BanConfigParser.split_multiline(raw)
|
||||
assert len(result) == 2
|
||||
assert all("HOST" in r for r in result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMultipleFiles (integration-style tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultipleFilesIntegration:
|
||||
"""Tests that combine several files to verify merge order."""
|
||||
|
||||
def test_local_only_override(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path, "test.conf", "[Definition]\nfailregex = base\n")
|
||||
_write(tmp_path, "test.local", "[Definition]\nfailregex = local\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_with_overrides(tmp_path / "test.conf")
|
||||
assert parser.get("Definition", "failregex") == "local"
|
||||
|
||||
def test_before_then_conf_then_local(self, tmp_path: Path) -> None:
|
||||
# before.conf → test.conf → test.local (ascending priority)
|
||||
_write(tmp_path, "before.conf", "[Definition]\nsource = before\n")
|
||||
_write(
|
||||
tmp_path,
|
||||
"test.conf",
|
||||
"[INCLUDES]\nbefore = before.conf\n\n[Definition]\nsource = conf\n",
|
||||
)
|
||||
_write(tmp_path, "test.local", "[Definition]\nsource = local\n")
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_with_overrides(tmp_path / "test.conf")
|
||||
assert parser.get("Definition", "source") == "local"
|
||||
|
||||
def test_before_key_preserved_if_not_overridden(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path, "common.conf", "[DEFAULT]\nhost = <HOST>\n")
|
||||
_write(
|
||||
tmp_path,
|
||||
"filter.conf",
|
||||
"[INCLUDES]\nbefore = common.conf\n\n[Definition]\nfailregex = ^%(host)s\n",
|
||||
)
|
||||
parser = Fail2BanConfigParser()
|
||||
parser.read_file(tmp_path / "filter.conf")
|
||||
assert parser.defaults().get("host") == "<HOST>"
|
||||
assert parser.get("Definition", "failregex") == "^%(host)s"
|
||||
|
||||
def test_fresh_parser_has_no_state(self) -> None:
|
||||
p1 = Fail2BanConfigParser()
|
||||
p2 = Fail2BanConfigParser()
|
||||
assert p1.sections() == []
|
||||
assert p2.sections() == []
|
||||
assert p1 is not p2
|
||||
290
backend/tests/test_utils/test_config_writer.py
Normal file
290
backend/tests/test_utils/test_config_writer.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Tests for app.utils.config_writer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest # noqa: F401 — used by pytest.raises
|
||||
|
||||
from app.utils.config_writer import (
|
||||
_get_file_lock,
|
||||
delete_local_file,
|
||||
remove_local_key,
|
||||
write_local_override,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write(tmp_path: Path, name: str, content: str) -> Path:
|
||||
p = tmp_path / name
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestGetFileLock
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetFileLock:
|
||||
def test_same_path_returns_same_lock(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "test.local"
|
||||
lock_a = _get_file_lock(path)
|
||||
lock_b = _get_file_lock(path)
|
||||
assert lock_a is lock_b
|
||||
|
||||
def test_different_paths_return_different_locks(self, tmp_path: Path) -> None:
|
||||
lock_a = _get_file_lock(tmp_path / "a.local")
|
||||
lock_b = _get_file_lock(tmp_path / "b.local")
|
||||
assert lock_a is not lock_b
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestWriteLocalOverride
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWriteLocalOverride:
|
||||
def test_creates_new_file(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "sshd.local"
|
||||
write_local_override(path, "Definition", {"failregex": "^bad$"})
|
||||
assert path.is_file()
|
||||
|
||||
def test_file_contains_written_key(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "sshd.local"
|
||||
write_local_override(path, "Definition", {"failregex": "^bad$"})
|
||||
content = _read(path)
|
||||
assert "failregex" in content
|
||||
assert "^bad$" in content
|
||||
|
||||
def test_creates_parent_directory(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "subdir" / "sshd.local"
|
||||
write_local_override(path, "Definition", {"key": "val"})
|
||||
assert path.is_file()
|
||||
|
||||
def test_updates_existing_key(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "sshd.local"
|
||||
write_local_override(path, "Definition", {"failregex": "original"})
|
||||
write_local_override(path, "Definition", {"failregex": "updated"})
|
||||
content = _read(path)
|
||||
assert "updated" in content
|
||||
assert "original" not in content
|
||||
|
||||
def test_preserves_other_sections(self, tmp_path: Path) -> None:
|
||||
existing = "[Init]\nname = sshd\n\n[Definition]\nfailregex = orig\n"
|
||||
path = _write(tmp_path, "sshd.local", existing)
|
||||
write_local_override(path, "Definition", {"failregex": "new"})
|
||||
content = _read(path)
|
||||
assert "Init" in content
|
||||
assert "name" in content
|
||||
|
||||
def test_preserves_other_keys_in_section(self, tmp_path: Path) -> None:
|
||||
existing = "[Definition]\nfailregex = orig\nignoreregex = keep\n"
|
||||
path = _write(tmp_path, "sshd.local", existing)
|
||||
write_local_override(path, "Definition", {"failregex": "new"})
|
||||
content = _read(path)
|
||||
assert "ignoreregex" in content
|
||||
assert "keep" in content
|
||||
|
||||
def test_adds_new_section(self, tmp_path: Path) -> None:
|
||||
existing = "[Definition]\nfailregex = orig\n"
|
||||
path = _write(tmp_path, "sshd.local", existing)
|
||||
write_local_override(path, "Init", {"name": "sshd"})
|
||||
content = _read(path)
|
||||
assert "[Init]" in content
|
||||
assert "name" in content
|
||||
|
||||
def test_writes_multiple_keys(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "sshd.local"
|
||||
write_local_override(path, "Definition", {"a": "1", "b": "2", "c": "3"})
|
||||
content = _read(path)
|
||||
assert "a" in content
|
||||
assert "b" in content
|
||||
assert "c" in content
|
||||
|
||||
def test_raises_for_conf_path(self, tmp_path: Path) -> None:
|
||||
bad = tmp_path / "sshd.conf"
|
||||
with pytest.raises(ValueError, match=r"\.local"):
|
||||
write_local_override(bad, "Definition", {"key": "val"})
|
||||
|
||||
def test_raises_for_non_local_extension(self, tmp_path: Path) -> None:
|
||||
bad = tmp_path / "sshd.ini"
|
||||
with pytest.raises(ValueError):
|
||||
write_local_override(bad, "Definition", {"key": "val"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestRemoveLocalKey
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRemoveLocalKey:
|
||||
def test_removes_existing_key(self, tmp_path: Path) -> None:
|
||||
path = _write(
|
||||
tmp_path,
|
||||
"sshd.local",
|
||||
"[Definition]\nfailregex = bad\nignoreregex = keep\n",
|
||||
)
|
||||
remove_local_key(path, "Definition", "failregex")
|
||||
content = _read(path)
|
||||
assert "failregex" not in content
|
||||
assert "ignoreregex" in content
|
||||
|
||||
def test_noop_for_missing_key(self, tmp_path: Path) -> None:
|
||||
path = _write(tmp_path, "sshd.local", "[Definition]\nother = val\n")
|
||||
# Should not raise.
|
||||
remove_local_key(path, "Definition", "nonexistent")
|
||||
assert path.is_file()
|
||||
|
||||
def test_noop_for_missing_section(self, tmp_path: Path) -> None:
|
||||
path = _write(tmp_path, "sshd.local", "[Definition]\nother = val\n")
|
||||
remove_local_key(path, "Init", "name")
|
||||
assert path.is_file()
|
||||
|
||||
def test_noop_for_missing_file(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "missing.local"
|
||||
# Should not raise even if file doesn't exist.
|
||||
remove_local_key(path, "Definition", "key")
|
||||
|
||||
def test_removes_empty_section(self, tmp_path: Path) -> None:
|
||||
# [Definition] will become empty and be removed; [Init] keeps the file.
|
||||
path = _write(
|
||||
tmp_path,
|
||||
"sshd.local",
|
||||
"[Definition]\nonly_key = val\n\n[Init]\nname = sshd\n",
|
||||
)
|
||||
remove_local_key(path, "Definition", "only_key")
|
||||
content = _read(path)
|
||||
assert "[Definition]" not in content
|
||||
assert "[Init]" in content
|
||||
|
||||
def test_deletes_file_when_no_sections_remain(self, tmp_path: Path) -> None:
|
||||
path = _write(tmp_path, "sshd.local", "[Definition]\nonly_key = val\n")
|
||||
remove_local_key(path, "Definition", "only_key")
|
||||
assert not path.exists()
|
||||
|
||||
def test_preserves_other_sections_after_removal(self, tmp_path: Path) -> None:
|
||||
path = _write(
|
||||
tmp_path,
|
||||
"sshd.local",
|
||||
"[Definition]\nkey = val\n\n[Init]\nname = sshd\n",
|
||||
)
|
||||
remove_local_key(path, "Definition", "key")
|
||||
content = _read(path)
|
||||
assert "[Init]" in content
|
||||
assert "name" in content
|
||||
|
||||
def test_raises_for_conf_path(self, tmp_path: Path) -> None:
|
||||
bad = tmp_path / "sshd.conf"
|
||||
with pytest.raises(ValueError, match=r"\.local"):
|
||||
remove_local_key(bad, "Definition", "key")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestDeleteLocalFile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteLocalFile:
|
||||
def test_deletes_existing_local_with_conf(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path, "sshd.conf", "[Definition]\n")
|
||||
path = _write(tmp_path, "sshd.local", "[Definition]\nkey = val\n")
|
||||
delete_local_file(path)
|
||||
assert not path.exists()
|
||||
|
||||
def test_raises_file_not_found(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path, "sshd.conf", "[Definition]\n")
|
||||
missing = tmp_path / "sshd.local"
|
||||
with pytest.raises(FileNotFoundError):
|
||||
delete_local_file(missing)
|
||||
|
||||
def test_raises_oserror_for_orphan_without_flag(self, tmp_path: Path) -> None:
|
||||
path = _write(tmp_path, "orphan.local", "[Definition]\nkey = val\n")
|
||||
with pytest.raises(OSError, match="No corresponding .conf"):
|
||||
delete_local_file(path)
|
||||
|
||||
def test_allow_orphan_deletes_local_only_file(self, tmp_path: Path) -> None:
|
||||
path = _write(tmp_path, "orphan.local", "[Definition]\nkey = val\n")
|
||||
delete_local_file(path, allow_orphan=True)
|
||||
assert not path.exists()
|
||||
|
||||
def test_raises_for_conf_path(self, tmp_path: Path) -> None:
|
||||
bad = _write(tmp_path, "sshd.conf", "[Definition]\n")
|
||||
with pytest.raises(ValueError, match=r"\.local"):
|
||||
delete_local_file(bad)
|
||||
|
||||
def test_raises_for_non_local_extension(self, tmp_path: Path) -> None:
|
||||
bad = tmp_path / "sshd.ini"
|
||||
bad.write_text("x", encoding="utf-8")
|
||||
with pytest.raises(ValueError):
|
||||
delete_local_file(bad)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAtomicWrite (integration)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAtomicWrite:
|
||||
def test_no_temp_files_left_after_write(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "sshd.local"
|
||||
write_local_override(path, "Definition", {"key": "val"})
|
||||
files = list(tmp_path.iterdir())
|
||||
# Only the target file should exist.
|
||||
assert len(files) == 1
|
||||
assert files[0].name == "sshd.local"
|
||||
|
||||
def test_write_is_idempotent(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "sshd.local"
|
||||
for _ in range(5):
|
||||
write_local_override(path, "Definition", {"key": "val"})
|
||||
content = _read(path)
|
||||
# 'key' should appear exactly once.
|
||||
assert content.count("key") == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestEdgeCases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_write_empty_key_values_creates_empty_section(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
path = tmp_path / "sshd.local"
|
||||
write_local_override(path, "Definition", {})
|
||||
content = _read(path)
|
||||
assert "[Definition]" in content
|
||||
|
||||
def test_remove_key_with_unicode_value(self, tmp_path: Path) -> None:
|
||||
path = _write(
|
||||
tmp_path,
|
||||
"sshd.local",
|
||||
"[Definition]\nkey = 日本語\nother = keep\n",
|
||||
)
|
||||
remove_local_key(path, "Definition", "key")
|
||||
content = _read(path)
|
||||
assert "日本語" not in content
|
||||
assert "other" in content
|
||||
|
||||
def test_write_value_with_newlines(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "sshd.local"
|
||||
# configparser stores multi-line values with continuation indent.
|
||||
multiline = "line1\n line2\n line3"
|
||||
write_local_override(path, "Definition", {"failregex": multiline})
|
||||
assert path.is_file()
|
||||
|
||||
def test_remove_last_key_of_last_section_deletes_file(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
path = _write(tmp_path, "sshd.local", "[Definition]\nlast_key = val\n")
|
||||
remove_local_key(path, "Definition", "last_key")
|
||||
assert not path.exists()
|
||||
Reference in New Issue
Block a user