diff --git a/hate_crack.py b/hate_crack.py index b3366dd..7e190b1 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -95,6 +95,7 @@ def get_main_menu_options(): "80": _attacks.wordlist_tools_submenu, "81": _attacks.rule_tools_submenu, "83": toggle_notifications, + "84": test_pushover_notification, "90": download_hashmob_rules, "91": weakpass_wordlist_menu, "92": download_hashmob_wordlists, diff --git a/hate_crack/main.py b/hate_crack/main.py index bf8ec24..37f0772 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -4108,6 +4108,39 @@ def toggle_notifications(): ) +def test_pushover_notification(): + """Send a canned test notification so the user can verify Pushover works. + + Ignores the global ``notify_enabled`` toggle on purpose: the point of the + test is to confirm the wire is live, independent of whether attacks are + currently wired to notify. When the global toggle is OFF we still send + but print a note so the user is not surprised later. + """ + settings = _notify.get_settings() + token = settings.pushover_token + user = settings.pushover_user + if not token or not user: + print( + "\n[!] Pushover credentials missing. Set notify_pushover_token " + "and notify_pushover_user in config.json." + ) + return + + if not settings.enabled: + print("\n(notifications are globally OFF, but sending test anyway)") + + title = "hate_crack: test notification" + message = ( + "This is a test notification from hate_crack. " + "If you see this, Pushover is wired up correctly." + ) + ok = _notify._send_pushover(token, user, title, message) + if ok: + print("[+] Test Pushover notification sent. Check your device.") + else: + print("[!] Test Pushover notification failed. See log output for details.") + + def get_main_menu_items(): """Return ordered (key, label) pairs for the main menu.""" items = [ @@ -4136,6 +4169,7 @@ def get_main_menu_items(): "83", f"Toggle Pushover Notifications [{'ON' if _notify.get_settings().enabled else 'OFF'}]", ), + ("84", "Send Test Pushover Notification"), ("90", "Download rules from Hashmob.net"), ("91", "Analyze Hashcat Rules"), ("92", "Download wordlists from Hashmob.net"), @@ -4180,6 +4214,7 @@ def get_main_menu_options(): "80": wordlist_tools_submenu, "81": rule_tools_submenu, "83": toggle_notifications, + "84": test_pushover_notification, "90": lambda: download_hashmob_rules(rules_dir=rulesDirectory), "91": analyze_rules, "92": download_hashmob_wordlists, diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index 65a9ecc..df81093 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -33,6 +33,7 @@ MENU_OPTION_TEST_CASES = [ ("80", CLI_MODULE._attacks, "wordlist_tools_submenu", "wordlist-tools"), ("81", CLI_MODULE._attacks, "rule_tools_submenu", "rule-tools"), ("83", CLI_MODULE, "toggle_notifications", "toggle-notifications"), + ("84", CLI_MODULE, "test_pushover_notification", "test-pushover"), ("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"), ("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"), ("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"), diff --git a/tests/test_ui_test_pushover.py b/tests/test_ui_test_pushover.py new file mode 100644 index 0000000..f5b43b4 --- /dev/null +++ b/tests/test_ui_test_pushover.py @@ -0,0 +1,79 @@ +"""Unit tests for main.test_pushover_notification(). + +These tests patch ``hate_crack.main._notify`` directly rather than +``hate_crack.notify._send_pushover``. The latter is fragile because +``tests/test_random_rules_attack.py`` purges ``hate_crack.*`` from +``sys.modules`` and re-imports, which leaves ``main._notify`` pointing +at a different module object than the one a top-level ``patch`` would +touch. Patching the attribute on ``main`` itself is robust to that. + +We use ``patch.object(hc_main, "_notify")`` rather than +``patch("hate_crack.main._notify")`` so the patch target is the exact +module object whose function we invoke. A string target would re-resolve +``hate_crack.main`` through ``sys.modules``, which — again thanks to the +purge in ``test_random_rules_attack.py`` — can be a different object from +the ``hc_main`` reference bound at test-module import time. +""" + +from types import SimpleNamespace +from unittest.mock import patch + +from hate_crack import main as hc_main + + +def _settings( + *, enabled: bool = True, token: str = "tok", user: str = "usr" +) -> SimpleNamespace: + """Minimal stand-in for ``NotifySettings`` — we only read three fields.""" + return SimpleNamespace(enabled=enabled, pushover_token=token, pushover_user=user) + + +class TestTestPushoverNotification: + def test_success_prints_confirmation_and_sends(self, capsys): + with patch.object(hc_main, "_notify") as mock_notify: + mock_notify.get_settings.return_value = _settings(enabled=True) + mock_notify._send_pushover.return_value = True + hc_main.test_pushover_notification() + mock_notify._send_pushover.assert_called_once() + args = mock_notify._send_pushover.call_args.args + assert args[0] == "tok" + assert args[1] == "usr" + assert args[2] == "hate_crack: test notification" + assert "test notification from hate_crack" in args[3] + out = capsys.readouterr().out + assert "[+] Test Pushover notification sent" in out + + def test_failure_prints_failure_line(self, capsys): + with patch.object(hc_main, "_notify") as mock_notify: + mock_notify.get_settings.return_value = _settings(enabled=True) + mock_notify._send_pushover.return_value = False + hc_main.test_pushover_notification() + out = capsys.readouterr().out + assert "[!] Test Pushover notification failed" in out + + def test_missing_token_skips_send_and_warns(self, capsys): + with patch.object(hc_main, "_notify") as mock_notify: + mock_notify.get_settings.return_value = _settings(enabled=True, token="") + hc_main.test_pushover_notification() + mock_notify._send_pushover.assert_not_called() + out = capsys.readouterr().out + assert "[!] Pushover credentials missing" in out + assert "notify_pushover_token" in out + + def test_missing_user_skips_send_and_warns(self, capsys): + with patch.object(hc_main, "_notify") as mock_notify: + mock_notify.get_settings.return_value = _settings(enabled=True, user="") + hc_main.test_pushover_notification() + mock_notify._send_pushover.assert_not_called() + out = capsys.readouterr().out + assert "[!] Pushover credentials missing" in out + + def test_globally_off_still_sends_with_note(self, capsys): + with patch.object(hc_main, "_notify") as mock_notify: + mock_notify.get_settings.return_value = _settings(enabled=False) + mock_notify._send_pushover.return_value = True + hc_main.test_pushover_notification() + mock_notify._send_pushover.assert_called_once() + out = capsys.readouterr().out + assert "notifications are globally OFF" in out + assert "[+] Test Pushover notification sent" in out