Skip to content

Commit b7cffe8

Browse files
committed
Refactor sync data processing to handle PUSH and PULL modes with improved error handling for JSON payloads
1 parent 1108534 commit b7cffe8

2 files changed

Lines changed: 105 additions & 21 deletions

File tree

front/plugins/sync/sync.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -175,23 +175,35 @@ def main():
175175
if file_name != 'last_result.log':
176176
mylog('verbose', [f'[{pluginName}] Processing: "{file_name}"'])
177177

178-
# make sure the file has the correct name (e.g last_result.encoded.Node_1.1.log) to skip any otehr plugin files
179-
if len(file_name.split('.')) > 2:
180-
# Extract node name from either last_result.decoded.Node_1.1.log or last_result.Node_1.log
181-
parts = file_name.split('.')
182-
# If decoded/encoded file, node name is at index 2; otherwise at index 1
183-
syncHubNodeName = parts[2] if 'decoded' in file_name or 'encoded' in file_name else parts[1]
178+
# Only process sync artifacts:
179+
# PUSH mode (decoded): last_result.PLUGIN.decoded.NodeName.N.log (6 parts)
180+
# PULL mode: last_result.NodeName.log (3 parts, valid JSON)
181+
# Local plugin result files (last_result.ARPSCAN.log) are also 3 parts but
182+
# are pipe-delimited — catch and skip them via the JSONDecodeError guard below.
183+
parts = file_name.split('.')
184+
if len(parts) > 2:
185+
# Extract node name:
186+
# decoded/encoded: last_result.PLUGIN.decoded.NodeName.N.log → parts[3]
187+
# pull mode: last_result.NodeName.log → parts[1]
188+
if 'decoded' in file_name or 'encoded' in file_name:
189+
syncHubNodeName = parts[3]
190+
else:
191+
syncHubNodeName = parts[1]
184192

185193
file_path = f"{LOG_PATH}/{file_name}"
186194

187-
with open(file_path, 'r') as f:
188-
data = json.load(f)
195+
try:
196+
with open(file_path, 'r') as f:
197+
data = json.load(f)
189198
for device in data['data']:
190199
device['devMac'] = str(device['devMac']).lower()
191200
if device['devMac'].lower() not in unique_mac_addresses:
192201
device['devSyncHubNode'] = syncHubNodeName
193202
unique_mac_addresses.add(device['devMac'].lower())
194203
device_data.append(device)
204+
except (json.JSONDecodeError, KeyError):
205+
mylog('verbose', [f'[{pluginName}] Skipping "{file_name}" - not a valid sync JSON payload'])
206+
continue
195207

196208
# Rename the file to "processed_" + current name
197209
new_file_name = f"processed_{file_name}"

test/plugins/test_sync_protocol.py

Lines changed: 85 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,14 @@ def _get_data(api_token, node_url):
6868

6969

7070
def _node_name_from_filename(file_name: str) -> str:
71-
"""Mirror of the node-name extraction in sync.main()."""
71+
"""Mirror of the node-name extraction in sync.main() (Mode 3).
72+
73+
Real file formats produced by the system:
74+
PUSH (post-decode): last_result.PLUGIN.decoded.NodeName.N.log → parts[3]
75+
PULL: last_result.NodeName.log → parts[1]
76+
"""
7277
parts = file_name.split(".")
73-
return parts[2] if ("decoded" in file_name or "encoded" in file_name) else parts[1]
78+
return parts[3] if ("decoded" in file_name or "encoded" in file_name) else parts[1]
7479

7580

7681
def _should_delete_after_process(filename: str) -> bool:
@@ -306,23 +311,33 @@ def test_returns_empty_string_on_non_200(self):
306311

307312
class TestNodeNameExtraction:
308313

309-
def test_simple_filename(self):
310-
# last_result.MyNode.log → "MyNode"
314+
def test_pull_mode_filename(self):
315+
# PULL mode: last_result.MyNode.log → "MyNode"
311316
assert _node_name_from_filename("last_result.MyNode.log") == "MyNode"
312317

313-
def test_decoded_filename(self):
314-
# last_result.decoded.MyNode.1.log → "MyNode"
315-
assert _node_name_from_filename("last_result.decoded.MyNode.1.log") == "MyNode"
318+
def test_push_decoded_filename(self):
319+
# PUSH mode (post-decode): last_result.ARPSCAN.decoded.MyNode.1.log → "MyNode"
320+
assert _node_name_from_filename("last_result.ARPSCAN.decoded.MyNode.1.log") == "MyNode"
316321

317-
def test_encoded_filename(self):
318-
# last_result.encoded.MyNode.1.log → "MyNode"
319-
assert _node_name_from_filename("last_result.encoded.MyNode.1.log") == "MyNode"
322+
def test_push_encoded_filename(self):
323+
# PUSH mode (pre-decode): last_result.ARPSCAN.encoded.MyNode.1.log → "MyNode"
324+
assert _node_name_from_filename("last_result.ARPSCAN.encoded.MyNode.1.log") == "MyNode"
320325

321-
def test_node_name_with_underscores(self):
326+
def test_pull_node_name_with_underscores(self):
322327
assert _node_name_from_filename("last_result.Wladek_Site.log") == "Wladek_Site"
323328

324-
def test_decoded_node_name_with_underscores(self):
325-
assert _node_name_from_filename("last_result.decoded.Wladek_Site.1.log") == "Wladek_Site"
329+
def test_push_decoded_node_name_with_underscores(self):
330+
assert _node_name_from_filename("last_result.ARPSCAN.decoded.Wladek_Site.1.log") == "Wladek_Site"
331+
332+
def test_push_decoded_node_name_with_counter_gt_1(self):
333+
# Counter increments when multiple pushes arrive before SYNC runs
334+
assert _node_name_from_filename("last_result.ARPSCAN.decoded.Node_Vlan01.3.log") == "Node_Vlan01"
335+
336+
def test_push_decoded_different_plugins(self):
337+
for plugin in ("NMAP", "PIHOLE", "DHCPLEASES"):
338+
fname = f"last_result.{plugin}.decoded.HubNode.1.log"
339+
assert _node_name_from_filename(fname) == "HubNode", \
340+
f"Expected 'HubNode' from {fname}"
326341

327342

328343
# ===========================================================================
@@ -465,3 +480,60 @@ def test_decoded_files_with_various_node_names_flagged(self):
465480

466481
def test_empty_device_list_returns_zero(self, conn):
467482
assert sync_insert_devices(conn, [], existing_macs=set()) == 0
483+
484+
485+
# ===========================================================================
486+
# Mode 3 JSON-skip behaviour
487+
# Regression: local plugin result files (pipe-delimited) must not crash Mode 3.
488+
# ===========================================================================
489+
490+
def _parse_sync_payload(file_path: str) -> list:
491+
"""Mirror of the json.load + data['data'] block in sync.main() Mode 3.
492+
493+
Returns the list of device dicts on success, or raises nothing on invalid
494+
input — callers should catch JSONDecodeError / KeyError and skip the file.
495+
"""
496+
import json as _json
497+
with open(file_path, "r") as f:
498+
data = _json.load(f)
499+
return data["data"]
500+
501+
502+
class TestMode3JsonSkip:
503+
"""Regression for the crash when Mode 3 encountered pipe-delimited plugin files.
504+
505+
Before the fix, sync.py called json.load() on every last_result.*.log file
506+
returned by decode_and_rename_files(), including local plugin result files
507+
(e.g. last_result.DIGSCAN.log) which are pipe-delimited and not JSON. The
508+
fix wraps the load in try/except(JSONDecodeError, KeyError) and continues.
509+
"""
510+
511+
def test_valid_sync_payload_is_parsed(self, tmp_path):
512+
import json
513+
payload = {"data": [{"devMac": "aa:bb:cc:dd:ee:01", "devName": "TestDevice"}]}
514+
f = tmp_path / "last_result.ARPSCAN.decoded.Node1.1.log"
515+
f.write_text(json.dumps(payload))
516+
result = _parse_sync_payload(str(f))
517+
assert len(result) == 1
518+
assert result[0]["devMac"] == "aa:bb:cc:dd:ee:01"
519+
520+
def test_pipe_delimited_file_raises_json_error(self, tmp_path):
521+
"""Pipe-delimited plugin file must raise JSONDecodeError so callers can skip it."""
522+
f = tmp_path / "last_result.DIGSCAN.log"
523+
f.write_text("aa:bb:cc:dd:ee:01|192.168.1.1|2026-01-01 00:00:00|hostname||subnet||DIGSCAN|||||\n")
524+
with pytest.raises(json.JSONDecodeError):
525+
_parse_sync_payload(str(f))
526+
527+
def test_json_without_data_key_raises_key_error(self, tmp_path):
528+
"""JSON that lacks the 'data' key must raise KeyError so callers can skip it."""
529+
import json
530+
f = tmp_path / "last_result.UNKNOWN.log"
531+
f.write_text(json.dumps({"result": []}))
532+
with pytest.raises(KeyError):
533+
_parse_sync_payload(str(f))
534+
535+
def test_empty_file_raises_json_error(self, tmp_path):
536+
f = tmp_path / "last_result.EMPTY.log"
537+
f.write_text("")
538+
with pytest.raises(json.JSONDecodeError):
539+
_parse_sync_payload(str(f))

0 commit comments

Comments
 (0)