|
29 | 29 | write_version_manifest, |
30 | 30 | snapshot_version, |
31 | 31 | get_versions_list, |
| 32 | + revert_csv_pipeline, |
32 | 33 | ) |
| 34 | +import wl_versions as wl_versions_module |
| 35 | +import wl_audit as wl_audit_module |
| 36 | +from wl_csv import write_csv as _write_csv_helper |
33 | 37 |
|
34 | 38 |
|
35 | 39 | @pytest.mark.unit |
@@ -641,3 +645,341 @@ def test_get_versions_list_filename_without_csv_extension(self, tmp_path): |
641 | 645 | assert len(result) == 1 |
642 | 646 | # Version ID should be extracted even without .csv |
643 | 647 | assert result[0]["version_id"] == "20260331_203045" |
| 648 | + |
| 649 | + |
| 650 | +# ═════════════════════════════════════════════════════════════════════════════ |
| 651 | +# Test: revert_csv_pipeline — covers wl_versions.py:377-658 (G3 batch 3b) |
| 652 | +# |
| 653 | +# This 280+ line pipeline function was uncovered prior to G3. Tests use |
| 654 | +# tmp_path for real filesystem I/O (CSV reads/writes, manifest, version |
| 655 | +# snapshots) and mock post_audit_event so no actual Splunk REST call fires. |
| 656 | +# ═════════════════════════════════════════════════════════════════════════════ |
| 657 | + |
| 658 | + |
| 659 | +def _setup_csv_with_version(tmp_path, current_rows, version_rows, |
| 660 | + headers=("name", "value")): |
| 661 | + """Build a CSV + a version snapshot + a manifest in tmp_path. |
| 662 | +
|
| 663 | + Returns (csv_path, version_filename, version_display). |
| 664 | + """ |
| 665 | + csv_path = tmp_path / "test.csv" |
| 666 | + versions_dir = tmp_path / "_versions" |
| 667 | + versions_dir.mkdir() |
| 668 | + |
| 669 | + _write_csv_helper(str(csv_path), list(headers), current_rows) |
| 670 | + |
| 671 | + version_filename = "test_20260101_120000.csv" |
| 672 | + version_path = versions_dir / version_filename |
| 673 | + _write_csv_helper(str(version_path), list(headers), version_rows) |
| 674 | + |
| 675 | + # Write a manifest that references the version snapshot |
| 676 | + manifest_path = versions_dir / "test_versions.json" |
| 677 | + manifest = { |
| 678 | + "versions": [ |
| 679 | + { |
| 680 | + "timestamp": "2026-01-01T12:00:00Z", |
| 681 | + "display": "01-01-2026 12:00:00", |
| 682 | + "filename": version_filename, |
| 683 | + "analyst": "tester", |
| 684 | + "action": "save", |
| 685 | + "row_count": len(version_rows), |
| 686 | + "col_count": len(headers), |
| 687 | + } |
| 688 | + ] |
| 689 | + } |
| 690 | + manifest_path.write_text(json.dumps(manifest)) |
| 691 | + |
| 692 | + return str(csv_path), version_filename, "01-01-2026 12:00:00" |
| 693 | + |
| 694 | + |
| 695 | +@pytest.mark.unit |
| 696 | +class TestRevertCsvPipeline: |
| 697 | + """Cover revert_csv_pipeline at bin/wl_versions.py:377-658.""" |
| 698 | + |
| 699 | + def test_happy_path_overwrites_csv_and_returns_success(self, tmp_path): |
| 700 | + """Current CSV is replaced by version content; audit event posted.""" |
| 701 | + current = [{"name": "Alice", "value": "1"}, {"name": "Bob", "value": "2"}] |
| 702 | + version = [{"name": "Alice", "value": "1"}] # version had only Alice |
| 703 | + csv_path, version_filename, version_display = _setup_csv_with_version( |
| 704 | + tmp_path, current, version |
| 705 | + ) |
| 706 | + |
| 707 | + with patch.object(wl_audit_module, "post_audit_event") as mock_post: |
| 708 | + mock_post.return_value = (True, "") |
| 709 | + result = revert_csv_pipeline( |
| 710 | + csv_path=csv_path, |
| 711 | + version_filename=version_filename, |
| 712 | + version_display=version_display, |
| 713 | + revert_reason="oops, restore Alice-only state", |
| 714 | + analyst="tester", |
| 715 | + session_key="test_session", |
| 716 | + csv_file="test.csv", |
| 717 | + app_context="wl_manager", |
| 718 | + detection_rule="DR1", |
| 719 | + ) |
| 720 | + |
| 721 | + assert result["success"] is True |
| 722 | + assert result["error"] == "" |
| 723 | + # CSV was overwritten with version content (only Alice now) |
| 724 | + from wl_csv import read_csv |
| 725 | + new_headers, new_rows = read_csv(csv_path) |
| 726 | + assert len(new_rows) == 1 |
| 727 | + assert new_rows[0]["name"] == "Alice" |
| 728 | + # Audit event posted |
| 729 | + assert mock_post.call_count == 1 |
| 730 | + evt = mock_post.call_args.args[1] |
| 731 | + assert evt["action"] == "revert" |
| 732 | + assert evt["analyst"] == "tester" |
| 733 | + assert evt["reverted_to_version"] == version_display |
| 734 | + |
| 735 | + def test_version_file_not_found_returns_error(self, tmp_path): |
| 736 | + """Non-existent version filename returns error without touching CSV.""" |
| 737 | + csv_path = tmp_path / "test.csv" |
| 738 | + (tmp_path / "_versions").mkdir() |
| 739 | + _write_csv_helper(str(csv_path), ["name"], [{"name": "Alice"}]) |
| 740 | + original_content = csv_path.read_text() |
| 741 | + |
| 742 | + with patch.object(wl_audit_module, "post_audit_event") as mock_post: |
| 743 | + result = revert_csv_pipeline( |
| 744 | + csv_path=str(csv_path), |
| 745 | + version_filename="nonexistent_99999999_000000.csv", |
| 746 | + version_display="bogus", |
| 747 | + revert_reason="testing missing version", |
| 748 | + analyst="tester", |
| 749 | + session_key="key", |
| 750 | + ) |
| 751 | + |
| 752 | + assert result["success"] is False |
| 753 | + assert "Version file not found" in result["error"] |
| 754 | + # CSV is unchanged |
| 755 | + assert csv_path.read_text() == original_content |
| 756 | + # No audit event posted (early return before audit) |
| 757 | + assert mock_post.call_count == 0 |
| 758 | + |
| 759 | + def test_audit_event_records_added_rows_as_restoredback(self, tmp_path): |
| 760 | + """Rows present in version but not current → restoredback_ lines.""" |
| 761 | + current = [{"name": "Alice", "value": "1"}] |
| 762 | + version = [ |
| 763 | + {"name": "Alice", "value": "1"}, |
| 764 | + {"name": "Bob", "value": "2"}, # added back by revert |
| 765 | + ] |
| 766 | + csv_path, vf, vd = _setup_csv_with_version(tmp_path, current, version) |
| 767 | + |
| 768 | + with patch.object(wl_audit_module, "post_audit_event") as mock_post: |
| 769 | + mock_post.return_value = (True, "") |
| 770 | + revert_csv_pipeline( |
| 771 | + csv_path=csv_path, |
| 772 | + version_filename=vf, |
| 773 | + version_display=vd, |
| 774 | + revert_reason="restore Bob", |
| 775 | + analyst="tester", |
| 776 | + session_key="key", |
| 777 | + ) |
| 778 | + |
| 779 | + evt = mock_post.call_args.args[1] |
| 780 | + value_lines = evt["value"] |
| 781 | + # Bob's row should appear in restoredback_ entries |
| 782 | + assert any("restoredback_name" in line and "Bob" in line for line in value_lines) |
| 783 | + assert evt["restoredback_row_count"] == 1 |
| 784 | + assert evt["removedback_row_count"] == 0 |
| 785 | + |
| 786 | + def test_audit_event_records_removed_rows_as_removedback(self, tmp_path): |
| 787 | + """Rows present in current but not version → removedback_ lines.""" |
| 788 | + current = [ |
| 789 | + {"name": "Alice", "value": "1"}, |
| 790 | + {"name": "Bob", "value": "2"}, |
| 791 | + ] |
| 792 | + version = [{"name": "Alice", "value": "1"}] # version doesn't have Bob |
| 793 | + csv_path, vf, vd = _setup_csv_with_version(tmp_path, current, version) |
| 794 | + |
| 795 | + with patch.object(wl_audit_module, "post_audit_event") as mock_post: |
| 796 | + mock_post.return_value = (True, "") |
| 797 | + revert_csv_pipeline( |
| 798 | + csv_path=csv_path, |
| 799 | + version_filename=vf, |
| 800 | + version_display=vd, |
| 801 | + revert_reason="remove Bob via revert", |
| 802 | + analyst="tester", |
| 803 | + session_key="key", |
| 804 | + ) |
| 805 | + |
| 806 | + evt = mock_post.call_args.args[1] |
| 807 | + value_lines = evt["value"] |
| 808 | + assert any("removedback_name" in line and "Bob" in line for line in value_lines) |
| 809 | + assert evt["removedback_row_count"] == 1 |
| 810 | + assert evt["restoredback_row_count"] == 0 |
| 811 | + |
| 812 | + def test_audit_event_records_edited_rows_as_changedback(self, tmp_path): |
| 813 | + """Same row keys but different values → changedback_ lines.""" |
| 814 | + current = [{"name": "Alice", "value": "current_value"}] |
| 815 | + version = [{"name": "Alice", "value": "old_value"}] |
| 816 | + csv_path, vf, vd = _setup_csv_with_version(tmp_path, current, version) |
| 817 | + |
| 818 | + with patch.object(wl_audit_module, "post_audit_event") as mock_post: |
| 819 | + mock_post.return_value = (True, "") |
| 820 | + revert_csv_pipeline( |
| 821 | + csv_path=csv_path, |
| 822 | + version_filename=vf, |
| 823 | + version_display=vd, |
| 824 | + revert_reason="restore old value", |
| 825 | + analyst="tester", |
| 826 | + session_key="key", |
| 827 | + ) |
| 828 | + |
| 829 | + evt = mock_post.call_args.args[1] |
| 830 | + value_lines = evt["value"] |
| 831 | + # Should contain a changedback_value line showing the field change |
| 832 | + assert any("changedback_value" in line for line in value_lines) |
| 833 | + assert evt["editedback_row_count"] == 1 |
| 834 | + |
| 835 | + def test_audit_event_skips_hidden_columns(self, tmp_path): |
| 836 | + """Headers starting with `_` are not surfaced in value lines.""" |
| 837 | + headers = ("name", "_internal_id", "value") |
| 838 | + current = [{"name": "Alice", "_internal_id": "x1", "value": "1"}] |
| 839 | + version = [{"name": "Alice", "_internal_id": "y1", "value": "1"}, |
| 840 | + {"name": "Bob", "_internal_id": "y2", "value": "2"}] |
| 841 | + csv_path, vf, vd = _setup_csv_with_version(tmp_path, current, version, headers=headers) |
| 842 | + |
| 843 | + with patch.object(wl_audit_module, "post_audit_event") as mock_post: |
| 844 | + mock_post.return_value = (True, "") |
| 845 | + revert_csv_pipeline( |
| 846 | + csv_path=csv_path, |
| 847 | + version_filename=vf, |
| 848 | + version_display=vd, |
| 849 | + revert_reason="restore Bob", |
| 850 | + analyst="tester", |
| 851 | + session_key="key", |
| 852 | + ) |
| 853 | + |
| 854 | + evt = mock_post.call_args.args[1] |
| 855 | + value_lines = evt["value"] |
| 856 | + # Hidden _internal_id must not appear in any audit value line |
| 857 | + assert not any("_internal_id" in line for line in value_lines), ( |
| 858 | + "value_lines should skip _-prefixed columns: {}".format(value_lines) |
| 859 | + ) |
| 860 | + # But the visible columns should appear |
| 861 | + assert any("name" in line for line in value_lines) |
| 862 | + |
| 863 | + def test_outer_oserror_returns_error_dict(self, tmp_path): |
| 864 | + """OSError during read_csv → returns error dict (lines 643-650).""" |
| 865 | + # Use a directory as csv_path → read_csv will raise (IsADirectoryError |
| 866 | + # / PermissionError depending on OS; both are OSError subclasses). |
| 867 | + target = tmp_path / "fake.csv" |
| 868 | + target.mkdir() # directory, not a file |
| 869 | + (tmp_path / "_versions").mkdir() |
| 870 | + |
| 871 | + with patch.object(wl_audit_module, "post_audit_event"): |
| 872 | + result = revert_csv_pipeline( |
| 873 | + csv_path=str(target), |
| 874 | + version_filename="anything.csv", |
| 875 | + version_display="any", |
| 876 | + revert_reason="testing oserror path", |
| 877 | + analyst="tester", |
| 878 | + session_key="key", |
| 879 | + ) |
| 880 | + |
| 881 | + assert result["success"] is False |
| 882 | + # The OSError branch wraps the message with "Failed to revert CSV" |
| 883 | + # OR the generic Exception branch wraps with "Unexpected error". |
| 884 | + # Either way, an error is returned. |
| 885 | + assert result["error"] != "" |
| 886 | + assert result["data"] == {} |
| 887 | + |
| 888 | + def test_generic_exception_returns_error_dict(self, tmp_path): |
| 889 | + """Non-OSError exception in pipeline → generic-exception branch (651-658).""" |
| 890 | + current = [{"name": "Alice", "value": "1"}] |
| 891 | + version = [{"name": "Bob", "value": "2"}] |
| 892 | + csv_path, vf, vd = _setup_csv_with_version(tmp_path, current, version) |
| 893 | + |
| 894 | + # Inject a RuntimeError by patching compute_diff (called inside pipeline) |
| 895 | + with patch.object(wl_versions_module, "compute_diff", |
| 896 | + side_effect=RuntimeError("synthetic failure")), \ |
| 897 | + patch.object(wl_audit_module, "post_audit_event"): |
| 898 | + result = revert_csv_pipeline( |
| 899 | + csv_path=csv_path, |
| 900 | + version_filename=vf, |
| 901 | + version_display=vd, |
| 902 | + revert_reason="trigger generic exception", |
| 903 | + analyst="tester", |
| 904 | + session_key="key", |
| 905 | + ) |
| 906 | + |
| 907 | + assert result["success"] is False |
| 908 | + assert "Unexpected error during revert" in result["error"] |
| 909 | + assert "synthetic failure" in result["error"] |
| 910 | + |
| 911 | + def test_revert_removes_source_version_from_manifest(self, tmp_path): |
| 912 | + """After revert, the source version entry is removed from manifest |
| 913 | + (avoids duplicate when the revert snapshot is later added).""" |
| 914 | + current = [{"name": "Alice", "value": "1"}] |
| 915 | + version = [{"name": "Bob", "value": "2"}] |
| 916 | + csv_path, vf, vd = _setup_csv_with_version(tmp_path, current, version) |
| 917 | + |
| 918 | + with patch.object(wl_audit_module, "post_audit_event"): |
| 919 | + revert_csv_pipeline( |
| 920 | + csv_path=csv_path, |
| 921 | + version_filename=vf, |
| 922 | + version_display=vd, |
| 923 | + revert_reason="cleanup test", |
| 924 | + analyst="tester", |
| 925 | + session_key="key", |
| 926 | + ) |
| 927 | + |
| 928 | + # Read the manifest post-revert: source version should be gone |
| 929 | + manifest, _ = read_version_manifest(csv_path) |
| 930 | + version_files = [ |
| 931 | + v.get("filename", "") for v in manifest.get("versions", []) |
| 932 | + ] |
| 933 | + assert vf not in version_files, ( |
| 934 | + "Source version {} should be removed from manifest. Got: {}".format( |
| 935 | + vf, version_files |
| 936 | + ) |
| 937 | + ) |
| 938 | + |
| 939 | + def test_revert_pipeline_records_column_position_changes(self, tmp_path): |
| 940 | + """If column order differs between current and version, moveback_column lines appear.""" |
| 941 | + # current has columns in order [name, value]; version has [value, name] |
| 942 | + current_headers = ("name", "value") |
| 943 | + version_headers = ("value", "name") |
| 944 | + current = [{"name": "Alice", "value": "1"}] |
| 945 | + version = [{"value": "1", "name": "Alice"}] |
| 946 | + |
| 947 | + csv_path = tmp_path / "test.csv" |
| 948 | + versions_dir = tmp_path / "_versions" |
| 949 | + versions_dir.mkdir() |
| 950 | + _write_csv_helper(str(csv_path), list(current_headers), current) |
| 951 | + vf = "test_20260101_120000.csv" |
| 952 | + _write_csv_helper(str(versions_dir / vf), list(version_headers), version) |
| 953 | + manifest_path = versions_dir / "test_versions.json" |
| 954 | + manifest_path.write_text(json.dumps({ |
| 955 | + "versions": [{ |
| 956 | + "filename": vf, |
| 957 | + "display": "01-01-2026 12:00:00", |
| 958 | + "timestamp": "2026-01-01T12:00:00Z", |
| 959 | + "analyst": "t", |
| 960 | + "action": "save", |
| 961 | + "row_count": 1, |
| 962 | + "col_count": 2, |
| 963 | + }] |
| 964 | + })) |
| 965 | + |
| 966 | + with patch.object(wl_audit_module, "post_audit_event") as mock_post: |
| 967 | + mock_post.return_value = (True, "") |
| 968 | + result = revert_csv_pipeline( |
| 969 | + csv_path=str(csv_path), |
| 970 | + version_filename=vf, |
| 971 | + version_display="01-01-2026 12:00:00", |
| 972 | + revert_reason="restore column order", |
| 973 | + analyst="tester", |
| 974 | + session_key="key", |
| 975 | + ) |
| 976 | + |
| 977 | + evt = mock_post.call_args.args[1] |
| 978 | + value_lines = evt["value"] |
| 979 | + # At least one moveback_column line should appear (columns swapped) |
| 980 | + moveback_col_lines = [l for l in value_lines if "moveback_column" in l] |
| 981 | + assert len(moveback_col_lines) > 0, ( |
| 982 | + "expected moveback_column lines when column order differs; got: {}" |
| 983 | + .format(value_lines) |
| 984 | + ) |
| 985 | + assert evt["moveback_column_count"] >= 1 |
0 commit comments