Skip to content

Commit df82528

Browse files
test(wl_trash): cover restore_csv_from_trash + restore_rule_from_trash (65% → 84%) — G6 batch 2
Adds 9 new tests in two classes: - TestRestoreCsvFromTrash (4 tests): name-conflict refusal, happy path (CSV + version snapshots moved correctly), rule-name triggers _restore_mapping_for_csv with mapping + registry persistence, empty rule_name skips mapping update. - TestRestoreRuleFromTrash (5 tests): rule-already-registered refusal, rule-already-in-mapping refusal, full happy path (multi-CSV restore + mapping recreation + registry re-register), malformed registry treated as empty list, rule with empty associated_csvs. Test-isolation note: discovered during this batch that `MAPPING_FILE` (constant `/opt/splunk/etc/apps/wl_manager/lookups/rule_csv_map.csv`) resolves to `C:/opt/splunk/...` on Windows due to leading-slash path interpretation. Tests that don't patch MAPPING_FILE leak state to that mirror directory, causing flake-style failures on re-runs. All new tests in this commit now patch BOTH `wl_trash.OWN_LOOKUPS` AND `wl_trash.MAPPING_FILE` to tmp_path-rooted paths to guarantee isolation. The polluted `C:/opt/splunk/.../rule_csv_map.csv` from earlier dev runs has been cleaned up (see session log). Coverage delta on bin/wl_trash.py: 65% → 84% (+19pp). Tests: 802 → 811 (+9 new). Full unit suite: 811 passed / 1 skipped. The remaining 51 missing lines in wl_trash.py are spread across small defensive branches (lines 175-182 trash_config_rmw_lock, 265-279 _move_versions_for_csv, 699-703 auto_cleanup_trash edge cases) — those are deferred to a follow-up batch if needed. Doc-drift: OK at build 663.
1 parent 05bd7f4 commit df82528

1 file changed

Lines changed: 241 additions & 0 deletions

File tree

tests/unit/test_trash.py

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
list_trash,
2626
restore_from_trash,
2727
restore_from_trash_pipeline,
28+
restore_csv_from_trash,
29+
restore_rule_from_trash,
2830
purge_trash_item,
2931
auto_cleanup_trash,
3032
get_trash_dir,
@@ -621,3 +623,242 @@ def test_restore_conflict(self, mock_restore):
621623

622624
assert result["success"] is False
623625
assert "already exists" in result["error"]
626+
627+
628+
@pytest.mark.unit
629+
class TestRestoreCsvFromTrash:
630+
"""Tests for restore_csv_from_trash (covers lines 469-509 +
631+
_restore_mapping_for_csv helper, currently the biggest coverage gap
632+
in wl_trash.py)."""
633+
634+
def _make_trash_item(self, tmp_path, csv_name="test.csv",
635+
app_ctx="", rule_name=""):
636+
"""Helper: build a trash item directory with the CSV inside."""
637+
lookups = tmp_path / "lookups"
638+
lookups.mkdir(exist_ok=True)
639+
trash = lookups / "_trash"
640+
trash.mkdir(exist_ok=True)
641+
trash_id = "test__csv_20260519_120000"
642+
item_dir = trash / trash_id
643+
item_dir.mkdir()
644+
(item_dir / csv_name).write_text("header\nvalue1\nvalue2")
645+
# Add a version snapshot too — should be restored to _versions/
646+
(item_dir / "test_20260519_115500.csv").write_text("old\n")
647+
meta = {
648+
"item_type": "csv",
649+
"name": csv_name,
650+
"app_context": app_ctx,
651+
"rule_name": rule_name,
652+
}
653+
return trash_id, str(item_dir), meta, lookups
654+
655+
def test_restore_csv_name_conflict_returns_error(self, tmp_path):
656+
"""If a file with the same name already exists at the dest, refuse."""
657+
trash_id, item_dir, meta, lookups = self._make_trash_item(tmp_path)
658+
# Create a file at the destination
659+
existing = lookups / "test.csv"
660+
existing.write_text("conflicting\n")
661+
662+
with patch("wl_trash.build_csv_path",
663+
return_value=str(existing)):
664+
result_meta, error = restore_csv_from_trash(
665+
trash_id, item_dir, meta)
666+
667+
assert "already exists" in error
668+
assert result_meta is meta
669+
670+
def test_restore_csv_happy_path(self, tmp_path):
671+
"""CSV restored to dest, version snapshots moved to _versions/."""
672+
trash_id, item_dir, meta, lookups = self._make_trash_item(tmp_path)
673+
dest = lookups / "test.csv"
674+
versions_dir = lookups / "_versions"
675+
676+
with patch("wl_trash.OWN_LOOKUPS", str(lookups)), \
677+
patch("wl_trash.build_csv_path", return_value=str(dest)):
678+
result_meta, error = restore_csv_from_trash(
679+
trash_id, item_dir, meta)
680+
681+
assert error == ""
682+
# CSV moved to destination
683+
assert dest.exists()
684+
assert "value1" in dest.read_text()
685+
# Version snapshot moved to _versions/
686+
assert versions_dir.exists()
687+
assert (versions_dir / "test_20260519_115500.csv").exists()
688+
689+
def test_restore_csv_with_rule_recreates_mapping(self, tmp_path):
690+
"""rule_name in metadata triggers _restore_mapping_for_csv."""
691+
trash_id, item_dir, meta, lookups = self._make_trash_item(
692+
tmp_path, rule_name="DR_restored")
693+
dest = lookups / "test.csv"
694+
mapping_path = lookups / "rule_csv_map.csv"
695+
rules_path = lookups / "_detection_rules.json"
696+
697+
with patch("wl_trash.OWN_LOOKUPS", str(lookups)), \
698+
patch("wl_trash.MAPPING_FILE", str(mapping_path)), \
699+
patch("wl_trash.build_csv_path", return_value=str(dest)):
700+
result_meta, error = restore_csv_from_trash(
701+
trash_id, item_dir, meta)
702+
703+
assert error == ""
704+
# Mapping created with the rule→CSV link
705+
assert mapping_path.exists()
706+
mapping_content = mapping_path.read_text()
707+
assert "DR_restored" in mapping_content
708+
assert "test.csv" in mapping_content
709+
# Rule appended to registry
710+
assert rules_path.exists()
711+
registered = json.loads(rules_path.read_text())
712+
assert "DR_restored" in registered
713+
714+
def test_restore_csv_no_rule_skips_mapping_update(self, tmp_path):
715+
"""If meta has empty rule_name, _restore_mapping_for_csv returns early."""
716+
trash_id, item_dir, meta, lookups = self._make_trash_item(
717+
tmp_path, rule_name="")
718+
dest = lookups / "test.csv"
719+
mapping_path = lookups / "rule_csv_map.csv"
720+
721+
with patch("wl_trash.OWN_LOOKUPS", str(lookups)), \
722+
patch("wl_trash.MAPPING_FILE", str(mapping_path)), \
723+
patch("wl_trash.build_csv_path", return_value=str(dest)):
724+
result_meta, error = restore_csv_from_trash(
725+
trash_id, item_dir, meta)
726+
727+
assert error == ""
728+
# No mapping file created
729+
assert not mapping_path.exists()
730+
731+
732+
@pytest.mark.unit
733+
class TestRestoreRuleFromTrash:
734+
"""Tests for restore_rule_from_trash (covers lines 575-647)."""
735+
736+
def _make_rule_trash_item(self, tmp_path, rule_name="DR_restored",
737+
csv_names=("DR_a.csv", "DR_b.csv")):
738+
"""Helper: build a trash item dir for a rule with associated CSVs."""
739+
lookups = tmp_path / "lookups"
740+
lookups.mkdir(exist_ok=True)
741+
trash = lookups / "_trash"
742+
trash.mkdir(exist_ok=True)
743+
trash_id = "DR_restored__rule_20260519_120000"
744+
item_dir = trash / trash_id
745+
item_dir.mkdir()
746+
# Each CSV stored under the trash item dir
747+
for csv_name in csv_names:
748+
(item_dir / csv_name).write_text(f"hdr\n{csv_name}_row\n")
749+
# Also a version snapshot
750+
(item_dir / "extra_version.csv").write_text("v\n")
751+
meta = {
752+
"item_type": "rule",
753+
"name": rule_name,
754+
"associated_csvs": [
755+
{"csv_file": c, "app_context": ""} for c in csv_names
756+
],
757+
}
758+
return trash_id, str(item_dir), meta, lookups
759+
760+
def test_restore_rule_already_registered_returns_error(self, tmp_path):
761+
"""If rule_name is already in _detection_rules.json, refuse."""
762+
trash_id, item_dir, meta, lookups = self._make_rule_trash_item(
763+
tmp_path)
764+
rules_path = lookups / "_detection_rules.json"
765+
rules_path.write_text(json.dumps(["DR_restored"])) # already present
766+
mapping_path = lookups / "rule_csv_map.csv"
767+
768+
with patch("wl_trash.OWN_LOOKUPS", str(lookups)), \
769+
patch("wl_trash.MAPPING_FILE", str(mapping_path)):
770+
result_meta, error = restore_rule_from_trash(
771+
trash_id, item_dir, meta)
772+
773+
assert "already exists" in error
774+
assert result_meta is meta
775+
776+
def test_restore_rule_already_in_mapping_returns_error(self, tmp_path):
777+
"""If rule_name is present in mapping CSV (even without registry), refuse."""
778+
trash_id, item_dir, meta, lookups = self._make_rule_trash_item(
779+
tmp_path)
780+
mapping_path = lookups / "rule_csv_map.csv"
781+
mapping_path.write_text(
782+
"rule_name,csv_file,app_context\nDR_restored,other.csv,\n")
783+
784+
with patch("wl_trash.OWN_LOOKUPS", str(lookups)), \
785+
patch("wl_trash.MAPPING_FILE", str(mapping_path)):
786+
result_meta, error = restore_rule_from_trash(
787+
trash_id, item_dir, meta)
788+
789+
assert "already exists" in error
790+
791+
def test_restore_rule_happy_path(self, tmp_path):
792+
"""All CSVs restored, mappings recreated, rule re-registered."""
793+
trash_id, item_dir, meta, lookups = self._make_rule_trash_item(
794+
tmp_path)
795+
rules_path = lookups / "_detection_rules.json"
796+
mapping_path = lookups / "rule_csv_map.csv"
797+
798+
# Compute destinations for each CSV
799+
def fake_build(csv_file, app_context=""):
800+
return str(lookups / csv_file)
801+
802+
with patch("wl_trash.OWN_LOOKUPS", str(lookups)), \
803+
patch("wl_trash.MAPPING_FILE", str(mapping_path)), \
804+
patch("wl_trash.build_csv_path", side_effect=fake_build):
805+
result_meta, error = restore_rule_from_trash(
806+
trash_id, item_dir, meta)
807+
808+
assert error == ""
809+
# Each CSV restored
810+
assert (lookups / "DR_a.csv").exists()
811+
assert (lookups / "DR_b.csv").exists()
812+
# Version snapshot moved to _versions/
813+
assert (lookups / "_versions" / "extra_version.csv").exists()
814+
# Rule registered
815+
registered = json.loads(rules_path.read_text())
816+
assert "DR_restored" in registered
817+
# Mapping has both CSVs
818+
mapping_text = mapping_path.read_text()
819+
assert "DR_a.csv" in mapping_text
820+
assert "DR_b.csv" in mapping_text
821+
822+
def test_restore_rule_handles_non_list_registry(self, tmp_path):
823+
"""If _detection_rules.json is malformed (not a list), treat as empty."""
824+
trash_id, item_dir, meta, lookups = self._make_rule_trash_item(
825+
tmp_path)
826+
rules_path = lookups / "_detection_rules.json"
827+
# Write a malformed registry (a dict, not a list)
828+
rules_path.write_text(json.dumps({"not": "a list"}))
829+
mapping_path = lookups / "rule_csv_map.csv"
830+
831+
def fake_build(csv_file, app_context=""):
832+
return str(lookups / csv_file)
833+
834+
with patch("wl_trash.OWN_LOOKUPS", str(lookups)), \
835+
patch("wl_trash.MAPPING_FILE", str(mapping_path)), \
836+
patch("wl_trash.build_csv_path", side_effect=fake_build):
837+
result_meta, error = restore_rule_from_trash(
838+
trash_id, item_dir, meta)
839+
840+
# Treated as empty registry → restore succeeds
841+
assert error == ""
842+
registered = json.loads(rules_path.read_text())
843+
assert "DR_restored" in registered
844+
845+
def test_restore_rule_with_no_associated_csvs(self, tmp_path):
846+
"""Rule with empty associated_csvs list still restores the rule entry."""
847+
trash_id, item_dir, meta, lookups = self._make_rule_trash_item(
848+
tmp_path, csv_names=())
849+
# Remove the version file too — minimal restore
850+
for f in os.listdir(item_dir):
851+
if f != "metadata.json":
852+
os.remove(os.path.join(item_dir, f))
853+
meta["associated_csvs"] = []
854+
rules_path = lookups / "_detection_rules.json"
855+
mapping_path = lookups / "rule_csv_map.csv"
856+
857+
with patch("wl_trash.OWN_LOOKUPS", str(lookups)), \
858+
patch("wl_trash.MAPPING_FILE", str(mapping_path)):
859+
result_meta, error = restore_rule_from_trash(
860+
trash_id, item_dir, meta)
861+
862+
assert error == ""
863+
registered = json.loads(rules_path.read_text())
864+
assert "DR_restored" in registered

0 commit comments

Comments
 (0)