Skip to content

Commit d267ea3

Browse files
fix(replay): revert_csv handler delegates to revert_csv_pipeline (build 663)
RELEASE-BLOCKER FIX: _execute_replay_revert_csv in bin/wl_replay.py had three production bugs in dead code that's wired into REPLAY_HANDLERS: 1. Line 226: `get_versions_dir()` called with no arguments, but the function signature in wl_versions.py:40 requires `csv_path: str`. Always raises TypeError caught by broad except → "revert_failed". 2. Line 236: `read_version_manifest(csv_file)` passed the filename instead of the resolved CSV path. The function would silently create a `_versions/` directory in cwd and return ({}, error). 3. Line 219/247: reads "version_id" from request_item, but the approval queue stores "version_filename" (see wl_handler.py:6768). Manifest schema also has no "version_id" field — the iteration at line 246-249 would fail even if reached. Additionally, REPLAY_HANDLERS["revert_csv"] was wired but the handler's process_approval path uses action_type="revert" (no _csv suffix, see wl_handler.py:6761) — dispatch keys didn't match. The fix replaces the entire broken body (109 lines) with a delegation to revert_csv_pipeline, matching the pattern used by create_rule / delete_rule / delete_csv handlers. This makes the replay path bit-for-bit identical to the direct-handler revert path and eliminates the dual-source-of-truth that allowed the bugs to ship. Changes: - bin/wl_replay.py: rewrite _execute_replay_revert_csv (109 → 85 lines) - bin/wl_replay.py: add "revert" alias in REPLAY_HANDLERS - bin/wl_replay.py: add "revert" to _csv_required_actions precondition set - default/app.conf: build 662 → 663 - appserver/static/whitelist_manager.js: urlArgs _b=662 → _b=663 - tests/unit/test_replay.py: replace TestRevertCsvHandlerKnownBug (which pinned the broken behavior) with TestRevertCsvHandler — 6 tests covering happy path, missing version_filename, pipeline failure, pipeline exception, legacy payload-nested submissions, and the new "revert" alias Production impact at the user-facing path: ZERO, because the handler short-circuits revert approvals at wl_handler.py:6761 by calling self._revert_csv directly (it never routes through execute_approved_action for reverts). The bugs were dormant. But the REPLAY_HANDLERS dispatch table is part of the architectural contract that future refactors will trust — leaving broken entries in it sets a trap for the next contributor who tries to consolidate replay paths. Coverage delta on bin/wl_replay.py: 79% → 92% (+13pp, on top of the +59pp from G3 batch 3c). Tests: 734 → 738 passed (+4 net after replacing 2 KnownBug pins with 6 fixed-behavior tests). Doc-drift: OK (checked 33 docs against build 663).
1 parent 11cf760 commit d267ea3

4 files changed

Lines changed: 180 additions & 117 deletions

File tree

appserver/static/whitelist_manager.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// disk cache. Splunk serves /static/@<server-hash>/... with Cache-Control:
1212
// public, max-age=31536000; without urlArgs, bumped build numbers don't force
1313
// a re-fetch and clients run stale JS until they hard-refresh.
14-
require.config({ urlArgs: "_b=662" });
14+
require.config({ urlArgs: "_b=663" });
1515
require([
1616
"jquery",
1717
"underscore",

bin/wl_replay.py

Lines changed: 67 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ def execute_approved_action(context: Dict[str, Any], request_item: Dict[str, Any
7373
}
7474

7575
# Validate preconditions for actions requiring CSV files
76-
_csv_required_actions = {"save_csv", "add_row", "remove_rows", "revert_csv"}
76+
_csv_required_actions = {"save_csv", "add_row", "remove_rows",
77+
"revert_csv", "revert"}
7778
if action_type in _csv_required_actions:
7879
csv_file = request_item.get("csv_file", "")
7980
app_context = request_item.get("app_context", "")
@@ -205,114 +206,90 @@ def _execute_replay_save_csv(context: Dict[str, Any], request_item: Dict[str, An
205206

206207
def _execute_replay_revert_csv(context: Dict[str, Any], request_item: Dict[str, Any]) -> Dict[str, Any]:
207208
"""
208-
Execute revert_csv action — restore CSV to a previous version.
209+
Execute revert_csv action — delegates to revert_csv_pipeline.
209210
210-
Args:
211-
context: Approval context with metadata
212-
request_item: Contains csv_file, app_context, version_id, comment
211+
Matches the delegation pattern used by create_rule / delete_rule /
212+
delete_csv handlers. The pipeline owns snapshot/diff/audit logic
213+
(single source of truth — keeps replay path and direct-handler
214+
path bit-for-bit identical).
213215
214-
Returns:
215-
Dict: {success: bool, message: str, data: dict (optional), error: str (optional)}
216+
The approval queue stores `version_filename` and `version_display`
217+
(set by the submitter's call to _submit_approval, see
218+
wl_handler.py:6768). Older payloads that nested these under
219+
`payload` are also honored.
216220
"""
217221
csv_file = request_item.get("csv_file", "")
218222
app_context = request_item.get("app_context", "")
219-
version_id = request_item.get("version_id", "")
220-
comment = request_item.get("comment", "")
221-
222-
try:
223-
# Get version manifest to find the version file
224-
from wl_versions import read_version_manifest, get_versions_dir
225-
226-
versions_dir = get_versions_dir()
227-
manifest_path = os.path.join(versions_dir, f"{csv_file}_versions.json")
228-
229-
if not os.path.isfile(manifest_path):
230-
return {
231-
"success": False,
232-
"error": "Version history not found",
233-
"error_type": "missing_versions"
234-
}
235-
236-
manifest, _ = read_version_manifest(csv_file)
237-
if not manifest:
238-
return {
239-
"success": False,
240-
"error": "Failed to read version manifest",
241-
"error_type": "manifest_error"
242-
}
243-
244-
# Find version file matching version_id
245-
version_file = None
246-
for v in manifest.get("versions", []):
247-
if v.get("version_id") == version_id:
248-
version_file = os.path.join(versions_dir, v.get("filename", ""))
249-
break
250-
251-
if not version_file or not os.path.isfile(version_file):
252-
return {
253-
"success": False,
254-
"error": "Version file not found",
255-
"error_type": "missing_version_file"
256-
}
257-
258-
# Read version CSV data
259-
headers, rows = read_csv(version_file)
260-
261-
# Write CSV to current location
262-
path = resolve_csv_path(csv_file, app_context)
263-
if path is None:
264-
fallback = os.path.join(OWN_LOOKUPS, csv_file)
265-
if os.path.isfile(fallback) and safe_realpath(fallback, APPS_DIR):
266-
path = safe_realpath(fallback, APPS_DIR)
223+
detection_rule = request_item.get("detection_rule", "")
267224

268-
if path is None:
269-
return {
270-
"success": False,
271-
"error": "CSV file not found",
272-
"error_type": "missing_csv"
273-
}
225+
payload = request_item.get("payload", {}) or {}
226+
version_filename = (request_item.get("version_filename", "")
227+
or payload.get("version_filename", ""))
228+
version_display = (request_item.get("version_display", "")
229+
or payload.get("version_display", ""))
230+
revert_reason = (request_item.get("revert_reason", "")
231+
or payload.get("revert_reason", "")
232+
or payload.get("comment", "")
233+
or request_item.get("comment", "")
234+
or "Approved via approval queue")
235+
236+
if not version_filename:
237+
return {
238+
"success": False,
239+
"error": "version_filename missing from approval payload",
240+
"error_type": "missing_version_filename",
241+
}
274242

275-
write_csv(path, headers, rows)
243+
path = resolve_csv_path(csv_file, app_context)
244+
if path is None:
245+
fallback = os.path.join(OWN_LOOKUPS, csv_file)
246+
if os.path.isfile(fallback) and safe_realpath(fallback, APPS_DIR):
247+
path = safe_realpath(fallback, APPS_DIR)
248+
if path is None:
249+
return {
250+
"success": False,
251+
"error": "CSV file not found",
252+
"error_type": "missing_csv",
253+
}
276254

277-
# Create new version snapshot of reverted state (path, analyst, action_label)
278-
analyst = context.get("original_analyst", "")
279-
new_version_id, _ = snapshot_version(path, analyst, "revert_csv")
255+
from wl_versions import revert_csv_pipeline
280256

281-
# Post audit event
282-
session_key = context.get("session_key", "")
283-
approving_admin = context.get("approving_admin", "")
284-
request_id = context.get("request_id", "")
257+
analyst = context.get("original_analyst", "")
258+
session_key = context.get("session_key", "")
285259

286-
audit_evt = build_audit_event(
287-
action="replay_revert_csv",
288-
analyst=approving_admin,
289-
detection_rule=request_item.get("detection_rule", ""),
260+
try:
261+
result = revert_csv_pipeline(
262+
csv_path=path,
263+
version_filename=version_filename,
264+
version_display=version_display,
265+
revert_reason=revert_reason,
266+
analyst=analyst,
267+
session_key=session_key,
290268
csv_file=csv_file,
291269
app_context=app_context,
292-
comment=f"Reverted to {version_id} by {approving_admin}. {comment}",
293-
request_id=request_id,
294-
reverted_to_version=version_id,
295-
new_record_version=new_version_id,
296-
original_analyst=analyst
270+
detection_rule=detection_rule,
297271
)
298-
299-
post_result, post_error = post_audit_event(session_key, audit_evt)
300-
if not post_result:
301-
_logger.error(f"Audit posting failed for replay_revert_csv: {post_error}")
302-
272+
except Exception as e:
273+
_logger.error("revert_csv_pipeline raised: %s", e, exc_info=True)
303274
return {
304-
"success": True,
305-
"message": "CSV reverted successfully",
306-
"data": {"version_id": new_version_id}
275+
"success": False,
276+
"error": str(e),
277+
"error_type": "revert_failed",
307278
}
308279

309-
except Exception as e:
280+
if not result.get("success"):
310281
return {
311282
"success": False,
312-
"error": str(e),
313-
"error_type": "revert_failed"
283+
"error": result.get("error", "Revert failed"),
284+
"error_type": "revert_failed",
314285
}
315286

287+
return {
288+
"success": True,
289+
"message": result.get("message", "CSV reverted successfully"),
290+
"data": result.get("data", {}),
291+
}
292+
316293

317294
def _execute_replay_create_rule(context: Dict[str, Any], request_item: Dict[str, Any]) -> Dict[str, Any]:
318295
"""
@@ -523,4 +500,5 @@ def _execute_replay_create_csv(context: Dict[str, Any], request_item: Dict[str,
523500
"remove_csv": _execute_replay_delete_csv, # Alias: approval queue uses remove_*
524501
"remove_rule": _execute_replay_delete_rule, # Alias: approval queue uses remove_*
525502
"revert_csv": _execute_replay_revert_csv,
503+
"revert": _execute_replay_revert_csv, # Alias: handler stores action_type="revert"
526504
}

default/app.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
[install]
77
is_configured = false
8-
build = 662
8+
build = 663
99

1010
[launcher]
1111
author = Oleh Bezsonov

tests/unit/test_replay.py

Lines changed: 111 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -618,59 +618,144 @@ def test_save_csv_failed_audit_post_still_returns_success(self):
618618
self.assertTrue(result["success"])
619619

620620

621-
class TestRevertCsvHandlerKnownBug(unittest.TestCase):
622-
"""Cover _execute_replay_revert_csv (lines 206-314).
623-
624-
KNOWN BUG (documented here to make the test stable against the fix):
625-
Line 226 calls `get_versions_dir()` with no arguments, but the function
626-
requires `csv_path: str`. In production this ALWAYS raises TypeError,
627-
which the broad `except Exception` at line 309 catches and returns as
628-
`revert_failed`. Lines 229-307 are effectively dead code in production
629-
until the bug is fixed.
630-
631-
We test the bug-caused error path, plus we cover the dead-code path
632-
by lazily patching the in-function imports of read_version_manifest
633-
and get_versions_dir at their source module (wl_versions).
621+
class TestRevertCsvHandler(unittest.TestCase):
622+
"""Cover _execute_replay_revert_csv (delegation to revert_csv_pipeline).
623+
624+
History: an earlier implementation called `get_versions_dir()` with
625+
no arguments (the function requires `csv_path: str`), and read
626+
`version_id` from request_item although the approval queue stores
627+
`version_filename`. Both bugs are fixed by delegating wholesale to
628+
`revert_csv_pipeline` (same pattern as create_rule/delete_rule
629+
handlers). These tests pin the FIXED behaviour.
634630
"""
635631

636632
def setUp(self):
637633
if execute_approved_action is None:
638634
self.skipTest("execute_approved_action not available")
639635

640-
def test_revert_csv_production_bug_returns_revert_failed(self):
641-
"""Document the dead-code bug: get_versions_dir() called with no args."""
636+
def test_revert_csv_happy_path_delegates_to_pipeline(self):
637+
"""Successful pipeline → success result + data forwarded."""
638+
import wl_replay
639+
import wl_versions
640+
with patch.object(wl_replay, "resolve_csv_path",
641+
return_value="/fake/x.csv"), \
642+
patch.object(wl_versions, "revert_csv_pipeline",
643+
return_value={"success": True,
644+
"message": "reverted",
645+
"data": {"new_record_version":
646+
"2026-05-19 ..."}}) as mock_pipe:
647+
result = execute_approved_action(
648+
{"session_key": "sk",
649+
"original_analyst": "alice",
650+
"approving_admin": "bob"},
651+
{"action_type": "revert_csv",
652+
"csv_file": "x.csv",
653+
"app_context": "wl_manager",
654+
"detection_rule": "DR100",
655+
"version_filename": "x_20260101_120000.csv",
656+
"version_display": "01-01-2026 12:00:00 (3 rows, by alice)",
657+
"revert_reason": "approved revert"},
658+
)
659+
self.assertTrue(result["success"])
660+
self.assertEqual(result["message"], "reverted")
661+
# Verify the delegation passes the expected kwargs
662+
mock_pipe.assert_called_once()
663+
kwargs = mock_pipe.call_args.kwargs
664+
self.assertEqual(kwargs["csv_path"], "/fake/x.csv")
665+
self.assertEqual(kwargs["version_filename"],
666+
"x_20260101_120000.csv")
667+
self.assertEqual(kwargs["version_display"],
668+
"01-01-2026 12:00:00 (3 rows, by alice)")
669+
self.assertEqual(kwargs["revert_reason"], "approved revert")
670+
self.assertEqual(kwargs["analyst"], "alice")
671+
self.assertEqual(kwargs["csv_file"], "x.csv")
672+
self.assertEqual(kwargs["app_context"], "wl_manager")
673+
self.assertEqual(kwargs["detection_rule"], "DR100")
674+
675+
def test_revert_csv_missing_version_filename_returns_error(self):
676+
"""No version_filename in payload → missing_version_filename error."""
642677
import wl_replay
643678
with patch.object(wl_replay, "resolve_csv_path",
644679
return_value="/fake/x.csv"):
645680
result = execute_approved_action(
646681
{"session_key": "sk"},
647682
{"action_type": "revert_csv",
648683
"csv_file": "x.csv",
649-
"version_id": "v123"},
684+
"app_context": "wl_manager",
685+
"payload": {}}, # no version_filename anywhere
686+
)
687+
self.assertFalse(result["success"])
688+
self.assertEqual(result["error_type"], "missing_version_filename")
689+
690+
def test_revert_csv_pipeline_failure_returns_revert_failed(self):
691+
"""Pipeline failure surfaces as revert_failed error_type."""
692+
import wl_replay
693+
import wl_versions
694+
with patch.object(wl_replay, "resolve_csv_path",
695+
return_value="/fake/x.csv"), \
696+
patch.object(wl_versions, "revert_csv_pipeline",
697+
return_value={"success": False,
698+
"error": "version file missing"}):
699+
result = execute_approved_action(
700+
{"session_key": "sk", "original_analyst": "alice"},
701+
{"action_type": "revert_csv",
702+
"csv_file": "x.csv",
703+
"version_filename": "x_20260101.csv"},
650704
)
651-
# The TypeError from get_versions_dir() is caught by the broad except
652705
self.assertFalse(result["success"])
653706
self.assertEqual(result["error_type"], "revert_failed")
654-
self.assertIn("missing", result["error"].lower())
707+
self.assertEqual(result["error"], "version file missing")
655708

656-
def test_revert_csv_missing_manifest_path_returns_missing_versions(self):
657-
"""If we patch around the bug, missing manifest → missing_versions."""
709+
def test_revert_csv_pipeline_exception_returns_revert_failed(self):
710+
"""If the pipeline RAISES, wrapper catches and returns revert_failed."""
658711
import wl_replay
659712
import wl_versions
660713
with patch.object(wl_replay, "resolve_csv_path",
661714
return_value="/fake/x.csv"), \
662-
patch.object(wl_versions, "get_versions_dir",
663-
return_value="/fake/_versions"), \
664-
patch("os.path.isfile", return_value=False):
715+
patch.object(wl_versions, "revert_csv_pipeline",
716+
side_effect=RuntimeError("disk full")):
665717
result = execute_approved_action(
666718
{"session_key": "sk"},
667719
{"action_type": "revert_csv",
668720
"csv_file": "x.csv",
669-
"version_id": "v123"},
721+
"version_filename": "x_20260101.csv"},
670722
)
671-
# Either missing_versions (path patched OK) or missing_csv (precondition)
672-
# — both are acceptable failure modes for this bug-around test.
673723
self.assertFalse(result["success"])
724+
self.assertEqual(result["error_type"], "revert_failed")
725+
self.assertIn("disk full", result["error"])
726+
727+
def test_revert_csv_legacy_payload_under_payload_key(self):
728+
"""Older queue entries nest version_filename under 'payload' — honored."""
729+
import wl_replay
730+
import wl_versions
731+
with patch.object(wl_replay, "resolve_csv_path",
732+
return_value="/fake/x.csv"), \
733+
patch.object(wl_versions, "revert_csv_pipeline",
734+
return_value={"success": True,
735+
"message": "ok"}) as mock_pipe:
736+
result = execute_approved_action(
737+
{"session_key": "sk"},
738+
{"action_type": "revert_csv",
739+
"csv_file": "x.csv",
740+
"payload": {"version_filename": "legacy_x_20260101.csv",
741+
"version_display": "legacy display"}},
742+
)
743+
self.assertTrue(result["success"])
744+
kwargs = mock_pipe.call_args.kwargs
745+
self.assertEqual(kwargs["version_filename"],
746+
"legacy_x_20260101.csv")
747+
748+
def test_revert_alias_dispatches_to_same_handler(self):
749+
"""action_type='revert' (no _csv suffix) routes to same handler.
750+
751+
The handler stores approval queue entries with action_type='revert'
752+
(see wl_handler.py:6761), not 'revert_csv'. The dispatch table
753+
must accept both.
754+
"""
755+
import wl_replay
756+
self.assertIn("revert", wl_replay.REPLAY_HANDLERS)
757+
self.assertIs(wl_replay.REPLAY_HANDLERS["revert"],
758+
wl_replay.REPLAY_HANDLERS["revert_csv"])
674759

675760

676761
class TestCreateCsvHandler(unittest.TestCase):

0 commit comments

Comments
 (0)