Skip to content

Commit 279d70c

Browse files
test(wl_csv): cover hash-registry helpers (remove + bootstrap) — G2 batch 1
Item G2 of v1.1 test-coverage push. The CSV expected-hash registry helpers were a coverage hole flagged by item D mutation testing (~90 uncovered lines, security-critical: registry-tamper detection). This batch closes them with 9 new unit tests in test_csv.py: remove_csv_expected_hash (lines 125-132): - test_remove_csv_expected_hash_removes_existing_entry — happy path - test_remove_csv_expected_hash_missing_entry_is_no_op — idempotent when CSV was never registered - test_remove_csv_expected_hash_no_registry_file_is_no_op — does not spuriously create the registry when called on a fresh install bootstrap_csv_expected_hashes (lines 135-217): - test_bootstrap_csv_expected_hashes_fresh_install — first run hashes all mapped CSVs + the rule_csv_map.csv sentinel; all marked new_csvs - test_bootstrap_csv_expected_hashes_detects_changed_csv — re-bootstrap after mutation surfaces in changed_csvs with old_hash != new_hash - test_bootstrap_csv_expected_hashes_detects_removed_csv — dropping a CSV from the mapping surfaces in removed_csvs; the sentinel CSV correctly shows up in changed_csvs too (mapping content shifted when the row was dropped — this is the laundering-correlation signal) - test_bootstrap_csv_expected_hashes_missing_csv_file — referenced CSV missing on disk → missing_files entry; hashed_count excludes it - test_bootstrap_csv_expected_hashes_missing_mapping_raises_oserror — fail-loud when rule_csv_map.csv is absent (not silently empty) - test_bootstrap_csv_expected_hashes_sentinel_csv_always_included — rule_csv_map.csv hashed even when mapping body is empty The HMAC sign/verify wrapping (wl_hmac_key.derive_hash_registry_key) falls back to sha256(FIM_HMAC_SALT) when /opt/splunk/etc/instance.cfg is absent on the test host, so registry I/O round-trips cleanly without mocks. read_expected_hashes is used in 3 of the tests to verify the post-write state. Coverage delta: bin/wl_csv.py 49% → 58% (+9pp, +41 covered lines). Total bin/ coverage: 1906 → 1947 covered lines. The 4 wl_csv coverage gaps remaining in the unit-testable core: - lines 244-249, 255-256, 399-402, 682, 739-740 — small remainders in compute_diff branches and column-width helpers (G2 batch 2) - lines 812-1140 (save_csv_pipeline ~329 lines) and 1190-1239 (create_csv_pipeline ~50 lines) are integration-tested only; per item D triage these require live Splunk REST mocking and are deferred to a dedicated integration-coverage push. Tests pass: 623/623 unit (was 614 — added 9).
1 parent f41b9b5 commit 279d70c

1 file changed

Lines changed: 213 additions & 1 deletion

File tree

tests/unit/test_csv.py

Lines changed: 213 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@
2222

2323
from wl_csv import (
2424
read_csv, write_csv, compute_diff, get_expire_column,
25-
remove_expired_rows, get_column_widths, set_column_widths
25+
remove_expired_rows, get_column_widths, set_column_widths,
26+
update_csv_expected_hash, remove_csv_expected_hash,
27+
bootstrap_csv_expected_hashes, CSV_EXPECTED_HASHES_FILE,
2628
)
29+
from wl_constants import VERSIONS_DIR
30+
from wl_hmac_key import read_expected_hashes
2731

2832

2933
# ═════════════════════════════════════════════════════════════════════════════
@@ -599,3 +603,211 @@ def test_set_column_widths_empty_dict(tmp_path):
599603
widths_file = versions_dir / "test_colwidths.json"
600604
assert widths_file.exists()
601605
assert widths_file.read_text() == "{}"
606+
607+
608+
# ═════════════════════════════════════════════════════════════════════════════
609+
# Test: CSV expected-hashes registry (item G2 coverage push, 2026-05-19)
610+
#
611+
# Covers lines 125-217 in bin/wl_csv.py: remove_csv_expected_hash and
612+
# bootstrap_csv_expected_hashes. These are security-critical (CSV integrity
613+
# monitoring) and had zero unit-test coverage before item G2. The functions
614+
# are wrapped by HMAC sign/verify via wl_hmac_key — derive_hash_registry_key
615+
# falls back to sha256(FIM_HMAC_SALT) when /opt/splunk/etc/instance.cfg is
616+
# absent (test host), so the registry is signed deterministically and reads
617+
# round-trip cleanly without mocking.
618+
# ═════════════════════════════════════════════════════════════════════════════
619+
620+
621+
def _make_lookups_dir(tmp_path):
622+
"""Build a lookups/ tree with rule_csv_map.csv and _versions/ subdir."""
623+
lookups = tmp_path / "lookups"
624+
lookups.mkdir()
625+
(lookups / VERSIONS_DIR).mkdir()
626+
return lookups
627+
628+
629+
@pytest.mark.unit
630+
def test_remove_csv_expected_hash_removes_existing_entry(tmp_path):
631+
"""remove_csv_expected_hash drops one entry but leaves others intact."""
632+
lookups = _make_lookups_dir(tmp_path)
633+
csv_a = lookups / "a.csv"
634+
csv_a.write_text("name\nAlice\n")
635+
csv_b = lookups / "b.csv"
636+
csv_b.write_text("name\nBob\n")
637+
# Register both
638+
update_csv_expected_hash(str(csv_a))
639+
update_csv_expected_hash(str(csv_b))
640+
641+
remove_csv_expected_hash(str(csv_a))
642+
643+
hashes_path = lookups / VERSIONS_DIR / CSV_EXPECTED_HASHES_FILE
644+
hashes = read_expected_hashes(str(hashes_path))
645+
assert "a.csv" not in hashes
646+
assert "b.csv" in hashes
647+
648+
649+
@pytest.mark.unit
650+
def test_remove_csv_expected_hash_missing_entry_is_no_op(tmp_path):
651+
"""Removing an entry that isn't registered must not raise or corrupt."""
652+
lookups = _make_lookups_dir(tmp_path)
653+
csv_a = lookups / "a.csv"
654+
csv_a.write_text("name\nAlice\n")
655+
update_csv_expected_hash(str(csv_a))
656+
657+
# Try to remove a CSV that was never registered
658+
fake = lookups / "never_registered.csv"
659+
remove_csv_expected_hash(str(fake)) # must not raise
660+
661+
hashes_path = lookups / VERSIONS_DIR / CSV_EXPECTED_HASHES_FILE
662+
hashes = read_expected_hashes(str(hashes_path))
663+
assert "a.csv" in hashes # original entry preserved
664+
665+
666+
@pytest.mark.unit
667+
def test_remove_csv_expected_hash_no_registry_file_is_no_op(tmp_path):
668+
"""Removing from a registry that doesn't exist yet must not raise."""
669+
lookups = _make_lookups_dir(tmp_path)
670+
csv_a = lookups / "a.csv"
671+
csv_a.write_text("name\nAlice\n")
672+
# No prior update_csv_expected_hash call — registry file absent
673+
674+
remove_csv_expected_hash(str(csv_a)) # must not raise
675+
676+
# Registry should still be absent (no spurious write)
677+
hashes_path = lookups / VERSIONS_DIR / CSV_EXPECTED_HASHES_FILE
678+
assert not hashes_path.exists()
679+
680+
681+
@pytest.mark.unit
682+
def test_bootstrap_csv_expected_hashes_fresh_install(tmp_path):
683+
"""First bootstrap: every CSV is new, registry is created from scratch."""
684+
lookups = _make_lookups_dir(tmp_path)
685+
(lookups / "rule_csv_map.csv").write_text(
686+
"rule_name,csv_file,app_context\n"
687+
"R1,a.csv,wl_manager\n"
688+
"R2,b.csv,wl_manager\n"
689+
)
690+
(lookups / "a.csv").write_text("name\nAlice\n")
691+
(lookups / "b.csv").write_text("name\nBob\n")
692+
693+
result = bootstrap_csv_expected_hashes(str(lookups))
694+
695+
# Includes sentinel rule_csv_map.csv plus 2 mapped CSVs = 3
696+
assert result["hashed_count"] == 3
697+
assert result["missing_count"] == 0
698+
assert result["missing_files"] == []
699+
# All 3 are new on first bootstrap
700+
assert set(result["new_csvs"]) == {"a.csv", "b.csv", "rule_csv_map.csv"}
701+
assert result["changed_csvs"] == []
702+
assert result["removed_csvs"] == []
703+
704+
# Registry written and HMAC-verifies on round-trip
705+
hashes_path = lookups / VERSIONS_DIR / CSV_EXPECTED_HASHES_FILE
706+
assert hashes_path.exists()
707+
hashes = read_expected_hashes(str(hashes_path))
708+
assert set(hashes.keys()) == {"a.csv", "b.csv", "rule_csv_map.csv"}
709+
710+
711+
@pytest.mark.unit
712+
def test_bootstrap_csv_expected_hashes_detects_changed_csv(tmp_path):
713+
"""Re-bootstrap after a CSV's content changes records old + new hashes."""
714+
lookups = _make_lookups_dir(tmp_path)
715+
(lookups / "rule_csv_map.csv").write_text(
716+
"rule_name,csv_file,app_context\nR1,a.csv,wl_manager\n"
717+
)
718+
a_csv = lookups / "a.csv"
719+
a_csv.write_text("name\nAlice\n")
720+
721+
# First bootstrap establishes baseline
722+
first = bootstrap_csv_expected_hashes(str(lookups))
723+
old_a_hash = first["new_csvs"] # has a.csv as new
724+
assert "a.csv" in old_a_hash
725+
726+
# Mutate the CSV
727+
a_csv.write_text("name\nAlice\nBob\n")
728+
729+
# Second bootstrap detects the change
730+
second = bootstrap_csv_expected_hashes(str(lookups))
731+
assert second["new_csvs"] == [] # nothing new
732+
changed_names = [c["csv_file"] for c in second["changed_csvs"]]
733+
assert "a.csv" in changed_names
734+
# The diff entry has the old and new hashes
735+
a_entry = next(c for c in second["changed_csvs"] if c["csv_file"] == "a.csv")
736+
assert a_entry["old_hash"] != a_entry["new_hash"]
737+
assert len(a_entry["old_hash"]) == 64 # SHA-256 hex
738+
assert len(a_entry["new_hash"]) == 64
739+
740+
741+
@pytest.mark.unit
742+
def test_bootstrap_csv_expected_hashes_detects_removed_csv(tmp_path):
743+
"""Re-bootstrap after a CSV is dropped from mapping records it in removed_csvs."""
744+
lookups = _make_lookups_dir(tmp_path)
745+
(lookups / "rule_csv_map.csv").write_text(
746+
"rule_name,csv_file,app_context\n"
747+
"R1,a.csv,wl_manager\n"
748+
"R2,b.csv,wl_manager\n"
749+
)
750+
(lookups / "a.csv").write_text("name\nAlice\n")
751+
(lookups / "b.csv").write_text("name\nBob\n")
752+
bootstrap_csv_expected_hashes(str(lookups))
753+
754+
# Drop b.csv from the mapping AND remove the file
755+
(lookups / "rule_csv_map.csv").write_text(
756+
"rule_name,csv_file,app_context\nR1,a.csv,wl_manager\n"
757+
)
758+
(lookups / "b.csv").unlink()
759+
760+
result = bootstrap_csv_expected_hashes(str(lookups))
761+
assert "b.csv" in result["removed_csvs"]
762+
assert result["new_csvs"] == []
763+
# a.csv content unchanged but rule_csv_map.csv (the sentinel) IS changed
764+
# — its new content drops the b.csv row, so its hash differs. This is
765+
# the laundering-correlation signal: mapping edits surface as a
766+
# sentinel-CSV change in the same audit event as the removal.
767+
changed_names = [c["csv_file"] for c in result["changed_csvs"]]
768+
assert "a.csv" not in changed_names
769+
assert "rule_csv_map.csv" in changed_names
770+
771+
772+
@pytest.mark.unit
773+
def test_bootstrap_csv_expected_hashes_missing_csv_file(tmp_path):
774+
"""CSV listed in mapping but file absent on disk → missing_files entry."""
775+
lookups = _make_lookups_dir(tmp_path)
776+
(lookups / "rule_csv_map.csv").write_text(
777+
"rule_name,csv_file,app_context\n"
778+
"R1,a.csv,wl_manager\n"
779+
"R2,ghost.csv,wl_manager\n"
780+
)
781+
(lookups / "a.csv").write_text("name\nAlice\n")
782+
# ghost.csv referenced but never created
783+
784+
result = bootstrap_csv_expected_hashes(str(lookups))
785+
assert result["missing_count"] == 1
786+
assert "ghost.csv" in result["missing_files"]
787+
# a.csv + sentinel still hashed
788+
assert result["hashed_count"] == 2
789+
790+
791+
@pytest.mark.unit
792+
def test_bootstrap_csv_expected_hashes_missing_mapping_raises_oserror(tmp_path):
793+
"""Bootstrap with no rule_csv_map.csv must raise OSError (fail-loud)."""
794+
lookups = _make_lookups_dir(tmp_path)
795+
# No rule_csv_map.csv written
796+
797+
with pytest.raises(OSError) as exc_info:
798+
bootstrap_csv_expected_hashes(str(lookups))
799+
assert "rule_csv_map.csv" in str(exc_info.value)
800+
801+
802+
@pytest.mark.unit
803+
def test_bootstrap_csv_expected_hashes_sentinel_csv_always_included(tmp_path):
804+
"""rule_csv_map.csv itself is always hashed even when mapping is empty."""
805+
lookups = _make_lookups_dir(tmp_path)
806+
(lookups / "rule_csv_map.csv").write_text(
807+
"rule_name,csv_file,app_context\n" # header only, no rows
808+
)
809+
810+
result = bootstrap_csv_expected_hashes(str(lookups))
811+
assert result["hashed_count"] == 1
812+
assert result["new_csvs"] == ["rule_csv_map.csv"]
813+
assert result["missing_count"] == 0

0 commit comments

Comments
 (0)