From e7ee258d2a6399fe2199787be5aa1697269f5f72 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 22 Apr 2026 17:47:42 -0400 Subject: [PATCH 1/7] docs: add spec for test pushover menu entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: user needs a one-click way to verify Pushover credentials without running an attack. This spec captures the menu wiring, credential/toggle handling (option A — ignore global toggle), and test plan. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-22-test-pushover-menu-design.md | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .claude/specs/2026-04-22-test-pushover-menu-design.md diff --git a/.claude/specs/2026-04-22-test-pushover-menu-design.md b/.claude/specs/2026-04-22-test-pushover-menu-design.md new file mode 100644 index 0000000..c84a5e1 --- /dev/null +++ b/.claude/specs/2026-04-22-test-pushover-menu-design.md @@ -0,0 +1,96 @@ +# Test Pushover Notification — Menu Entry + +**Date:** 2026-04-22 +**Branch:** `feat/test-pushover-menu` + +## Goal + +Give the user a one-click way to verify that their Pushover credentials and +network path actually work, independent of running an attack. + +## User-facing behavior + +Main menu gains one new entry: + +``` + 84. Send Test Pushover Notification +``` + +Placed directly below the existing `83. Toggle Pushover Notifications` +entry so related controls sit together. + +Selecting it calls `_send_pushover(...)` once with a canned title/message: + +- **Title:** `hate_crack: test notification` +- **Message:** `This is a test notification from hate_crack. If you see this, Pushover is wired up correctly.` + +It then prints one of three lines to the terminal: + +1. **Credentials missing** — `notify_pushover_token` or `notify_pushover_user` + is empty in `config.json`: + > `[!] Pushover credentials missing. Set notify_pushover_token and notify_pushover_user in config.json.` +2. **Send succeeded** — `_send_pushover` returned `True`: + > `[+] Test Pushover notification sent. Check your device.` +3. **Send failed** — `_send_pushover` returned `False` (HTTP non-200, network + error, missing `requests`, etc.): + > `[!] Test Pushover notification failed. See log output for details.` + +### Global toggle behavior (option A) + +The test **ignores** the `notify_enabled` global toggle. The entire purpose +of the test is to verify the pipe works, and forcing the user to flip the +toggle first would be friction for no benefit. + +When the global toggle is OFF, we still send, but prepend an informational +line so the user is not confused later when no attack notifications arrive: + +> `(notifications are globally OFF, but sending test anyway)` + +## Implementation outline + +Follows the existing three-layer pattern for non-attack menu items (same +shape as `toggle_notifications`): + +1. **`hate_crack/main.py`** — new function `test_pushover_notification()`: + - Read `_notify.get_settings()` for token/user. + - Empty-creds branch -> print the missing-creds warning, return. + - If `settings.enabled is False`, print the "globally OFF" note. + - Call `_notify._send_pushover(token, user, title, message)`. + - Print success or failure line based on the returned bool. +2. **`hate_crack/main.py`** — menu wiring in `get_main_menu_items()` and + `get_main_menu_options()`: + - Add `("84", "Send Test Pushover Notification")` right after the `83` + entry. + - Add `"84": test_pushover_notification` to the options map. +3. **`hate_crack.py`** — menu wiring in its duplicate + `get_main_menu_options()`: + - Add `"84": test_pushover_notification`. Per the proxy pattern, + `test_pushover_notification` resolves through `__getattr__`. + +No new module, no new dependency, no new config key. + +## Testing + +Add one test in `tests/test_notify.py` (or a new `tests/test_menu_test_pushover.py` +if the existing file is already dense): + +- Monkeypatch `hate_crack.notify._send_pushover` to a stub returning `True`, + call `main.test_pushover_notification()`, assert stdout contains the + success line and the stub saw the canned title/message. +- Stub returning `False` -> assert failure line. +- Empty creds (`NotifySettings()` with blank token/user) -> assert + missing-creds line and stub **not called**. +- `enabled = False` with good creds -> assert the "globally OFF" note + appears and the stub **is** called. + +Menu-wiring test: extend `test_ui_menu_options.py` to assert `"84"` is in +both the `main.get_main_menu_options()` dict and the `hate_crack.py` +duplicate, and that both resolve to a callable. + +## Non-goals + +- No new backend (Slack, webhooks) — a sibling test for those can be added + when those backends exist. +- No interactive "enter token" prompt; the test assumes `config.json` is + already populated, matching how `toggle_notifications` behaves. +- No persistent state changes from running the test. From 6d61b02c847c2fb40be4703dce1171e606e3045f Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 22 Apr 2026 17:53:43 -0400 Subject: [PATCH 2/7] docs: add implementation plan for test pushover menu entry Five-task TDD plan: unit tests + function, main.py menu wiring, hate_crack.py proxy wiring, parametrized menu test, full verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-22-test-pushover-menu.md | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 .claude/plans/2026-04-22-test-pushover-menu.md diff --git a/.claude/plans/2026-04-22-test-pushover-menu.md b/.claude/plans/2026-04-22-test-pushover-menu.md new file mode 100644 index 0000000..8e7b449 --- /dev/null +++ b/.claude/plans/2026-04-22-test-pushover-menu.md @@ -0,0 +1,372 @@ +# Test Pushover Menu Entry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add main-menu entry `84. Send Test Pushover Notification` that sends a canned Pushover message to verify the user's credentials and network path. + +**Architecture:** One new function `test_pushover_notification()` in `hate_crack/main.py` that reads settings from the `notify` subsystem, calls the existing `notify._send_pushover(...)` once, and prints a one-line status to stdout. Two menu tables are updated (the main map in `main.py` and the duplicate in `hate_crack.py`). Per the spec, the test ignores the `notify_enabled` global toggle — if the toggle is OFF, we still send, but prepend an informational line so the user is not confused later. + +**Tech Stack:** Python 3, pytest, `unittest.mock.patch`, existing `hate_crack.notify` package. + +**Spec:** `.claude/specs/2026-04-22-test-pushover-menu-design.md` + +**Worktree:** `/tmp/hate_crack-test-pushover` on branch `feat/test-pushover-menu`. + +--- + +## File Map + +**New files:** +- `tests/test_ui_test_pushover.py` — unit tests for `test_pushover_notification()` + +**Modified files:** +- `hate_crack/main.py` + - Add function `test_pushover_notification()` near the existing `toggle_notifications()` (around line 4091) + - Add `("84", "Send Test Pushover Notification")` row to `get_main_menu_items()` + - Add `"84": test_pushover_notification` entry to `get_main_menu_options()` +- `hate_crack.py` + - Add `"84": test_pushover_notification` to the duplicate `get_main_menu_options()` (resolved via `__getattr__` proxy) +- `tests/test_ui_menu_options.py` + - Add `("84", CLI_MODULE, "test_pushover_notification", "test-pushover")` to `MENU_OPTION_TEST_CASES` + +--- + +## Task 1: Add `test_pushover_notification()` function (TDD) + +**Files:** +- Create: `tests/test_ui_test_pushover.py` +- Modify: `hate_crack/main.py` (new function near line 4091, after `toggle_notifications`) + +### Step 1: Write the failing test file + +- [ ] Create `tests/test_ui_test_pushover.py` with the full test module below. + +```python +"""Unit tests for main.test_pushover_notification().""" +from unittest.mock import patch + +import pytest + +from hate_crack import main as hc_main +from hate_crack import notify +from hate_crack.notify.settings import NotifySettings + + +@pytest.fixture(autouse=True) +def _reset_notify_state(): + notify.clear_state_for_tests() + yield + notify.clear_state_for_tests() + + +def _install_settings( + *, + enabled: bool = True, + token: str = "tok", + user: str = "usr", +) -> None: + """Swap fresh settings into the notify module for this test.""" + settings = NotifySettings() + settings.enabled = enabled + settings.pushover_token = token + settings.pushover_user = user + notify._settings = settings + + +class TestTestPushoverNotification: + def test_success_prints_confirmation_and_sends(self, capsys): + _install_settings(enabled=True, token="tok", user="usr") + with patch("hate_crack.notify._send_pushover", return_value=True) as send: + hc_main.test_pushover_notification() + assert send.called + args = send.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): + _install_settings(enabled=True, token="tok", user="usr") + with patch("hate_crack.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): + _install_settings(enabled=True, token="", user="usr") + with patch("hate_crack.notify._send_pushover") as send: + hc_main.test_pushover_notification() + send.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): + _install_settings(enabled=True, token="tok", user="") + with patch("hate_crack.notify._send_pushover") as send: + hc_main.test_pushover_notification() + send.assert_not_called() + out = capsys.readouterr().out + assert "[!] Pushover credentials missing" in out + + def test_globally_off_still_sends_with_note(self, capsys): + _install_settings(enabled=False, token="tok", user="usr") + with patch("hate_crack.notify._send_pushover", return_value=True) as send: + hc_main.test_pushover_notification() + send.assert_called_once() + out = capsys.readouterr().out + assert "notifications are globally OFF" in out + assert "[+] Test Pushover notification sent" in out +``` + +### Step 2: Run tests to confirm they fail + +- [ ] Run: `HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_ui_test_pushover.py -v` +- [ ] Expected: all five tests fail with `AttributeError: module 'hate_crack.main' has no attribute 'test_pushover_notification'`. + +### Step 3: Implement `test_pushover_notification()` in `main.py` + +- [ ] Open `hate_crack/main.py` and locate the `toggle_notifications()` function (around line 4091). +- [ ] Insert the following function directly below `toggle_notifications()` (i.e., between `toggle_notifications` and `get_main_menu_items`): + +```python +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.") +``` + +### Step 4: Run the new tests to confirm they pass + +- [ ] Run: `HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_ui_test_pushover.py -v` +- [ ] Expected: all five tests pass. + +### Step 5: Commit + +- [ ] Run: + +```bash +git add tests/test_ui_test_pushover.py hate_crack/main.py +git commit -m "$(cat <<'EOF' +feat(notify): add test_pushover_notification helper + +Canned send path so a user can verify Pushover credentials without +running an attack. Ignores the global notify_enabled toggle — the test's +purpose is to confirm the pipe is live, not that attack notifications +are enabled. Prints a note when the global toggle is OFF so the user is +not confused later. +EOF +)" +``` + +--- + +## Task 2: Wire the entry into `main.py`'s menu tables + +**Files:** +- Modify: `hate_crack/main.py` — two edits inside `get_main_menu_items()` and `get_main_menu_options()` + +### Step 1: Add the menu item row + +- [ ] Open `hate_crack/main.py` and find the `("83", f"Toggle Pushover Notifications [...]")` entry inside `get_main_menu_items()` (around line 4135). +- [ ] Directly below the closing `)` of that 83 tuple, insert: + +```python + ("84", "Send Test Pushover Notification"), +``` + +Context (before): + +```python + ( + "83", + f"Toggle Pushover Notifications [{'ON' if _notify.get_settings().enabled else 'OFF'}]", + ), + ("90", "Download rules from Hashmob.net"), +``` + +Context (after): + +```python + ( + "83", + f"Toggle Pushover Notifications [{'ON' if _notify.get_settings().enabled else 'OFF'}]", + ), + ("84", "Send Test Pushover Notification"), + ("90", "Download rules from Hashmob.net"), +``` + +### Step 2: Add the options map entry + +- [ ] In the same file find `"83": toggle_notifications,` inside `get_main_menu_options()` (around line 4182). +- [ ] Insert on the next line: + +```python + "84": test_pushover_notification, +``` + +Context (after): + +```python + "83": toggle_notifications, + "84": test_pushover_notification, + "90": lambda: download_hashmob_rules(rules_dir=rulesDirectory), +``` + +### Step 3: Sanity-check main.py loads + +- [ ] Run: `HATE_CRACK_SKIP_INIT=1 uv run python -c "from hate_crack import main; print(main.get_main_menu_options()['84'].__name__)"` +- [ ] Expected output ends with: `test_pushover_notification` + +### Step 4: Commit + +- [ ] Run: + +```bash +git add hate_crack/main.py +git commit -m "feat(notify): wire option 84 into main.py menu" +``` + +--- + +## Task 3: Wire the entry into `hate_crack.py`'s duplicate menu + +**Files:** +- Modify: `hate_crack.py` — one edit inside `get_main_menu_options()` + +### Step 1: Add the options map entry + +- [ ] Open `hate_crack.py` (repo root) and find `"83": toggle_notifications,` inside `get_main_menu_options()` (line 97). +- [ ] Insert on the next line: + +```python + "84": test_pushover_notification, +``` + +Note: `test_pushover_notification` is not explicitly imported in `hate_crack.py`; it resolves via the module's `__getattr__` proxy to `_main`. No import changes are needed. + +Context (after): + +```python + "81": _attacks.rule_tools_submenu, + "83": toggle_notifications, + "84": test_pushover_notification, + "90": download_hashmob_rules, +``` + +### Step 2: Sanity-check both menus agree on option 84 + +- [ ] Run: `HATE_CRACK_SKIP_INIT=1 uv run python -c "import importlib.util, pathlib; spec = importlib.util.spec_from_file_location('cli', pathlib.Path('hate_crack.py')); m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m); print(m.get_main_menu_options()['84'].__name__)"` +- [ ] Expected output ends with: `test_pushover_notification` + +### Step 3: Commit + +- [ ] Run: + +```bash +git add hate_crack.py +git commit -m "feat(notify): wire option 84 into hate_crack.py proxy menu" +``` + +--- + +## Task 4: Add parametrized menu-wiring test + +**Files:** +- Modify: `tests/test_ui_menu_options.py` — one row added to `MENU_OPTION_TEST_CASES` + +### Step 1: Add the test case row + +- [ ] Open `tests/test_ui_menu_options.py` and find the line: + +```python + ("83", CLI_MODULE, "toggle_notifications", "toggle-notifications"), +``` + +- [ ] Insert directly below it: + +```python + ("84", CLI_MODULE, "test_pushover_notification", "test-pushover"), +``` + +### Step 2: Run the menu test suite + +- [ ] Run: `HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_ui_menu_options.py -v` +- [ ] Expected: the new parametrized case `test_main_menu_option_returns_expected[84-...-test-pushover]` passes, and no existing cases regress. + +### Step 3: Commit + +- [ ] Run: + +```bash +git add tests/test_ui_menu_options.py +git commit -m "test(notify): cover option 84 in menu wiring parametrize" +``` + +--- + +## Task 5: Full verification + +**Files:** None. This task verifies the whole feature. + +### Step 1: Run the full test suite + +- [ ] Run: `HATE_CRACK_SKIP_INIT=1 uv run pytest -v` +- [ ] Expected: all tests pass. No new failures compared to the baseline on `feat/pushover-notifications`. + +### Step 2: Run lint + +- [ ] Run: `uv run ruff check hate_crack` +- [ ] Expected: no errors. If `ty` is available, also run `uv run ty check hate_crack`. + +### Step 3: Manual menu display spot-check (optional) + +- [ ] Run: `HATE_CRACK_SKIP_INIT=1 uv run python -c "from hate_crack import main; [print(f'{k}: {v}') for k, v in main.get_main_menu_items()]" | grep -E "^(83|84)"` +- [ ] Expected output (token/user state may flip ON/OFF): + +``` +83: Toggle Pushover Notifications [OFF] +84: Send Test Pushover Notification +``` + +### Step 4: No-op if prior tasks already committed cleanly + +- [ ] Run: `git status` +- [ ] Expected: `nothing to commit, working tree clean`. Branch has four commits ahead of `feat/pushover-notifications` (the four task commits). + +--- + +## Self-Review Results + +- **Spec coverage:** all four spec sections are covered — key `84` and label (Task 2+3), missing-creds/success/failure branches (Task 1), globally-OFF behavior (Task 1), menu wiring tests (Task 4). The unit test for the function is in a dedicated new file rather than extended into `test_notify.py` (the spec allowed either). +- **Placeholder scan:** no TBD/TODO tokens; every code step has literal code; every run step has a literal command and expected output. +- **Type consistency:** the function name `test_pushover_notification` is used identically across Tasks 1, 2, 3, and 4. The constants `"hate_crack: test notification"` and `"test notification from hate_crack"` appear verbatim in both the test assertions (Task 1 Step 1) and the implementation (Task 1 Step 3). From 316e9f3ec9c30582e2d600e89facaebd9da6e9d9 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 22 Apr 2026 17:56:35 -0400 Subject: [PATCH 3/7] feat(notify): add test_pushover_notification helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canned send path so a user can verify Pushover credentials without running an attack. Ignores the global notify_enabled toggle — the test's purpose is to confirm the pipe is live, not that attack notifications are enabled. Prints a note when the global toggle is OFF so the user is not confused later. --- hate_crack/main.py | 33 +++++++++++++++ tests/test_ui_test_pushover.py | 77 ++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tests/test_ui_test_pushover.py diff --git a/hate_crack/main.py b/hate_crack/main.py index bf8ec24..9778c39 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 = [ diff --git a/tests/test_ui_test_pushover.py b/tests/test_ui_test_pushover.py new file mode 100644 index 0000000..069faee --- /dev/null +++ b/tests/test_ui_test_pushover.py @@ -0,0 +1,77 @@ +"""Unit tests for main.test_pushover_notification().""" +from unittest.mock import patch + +import pytest + +from hate_crack import main as hc_main +from hate_crack import notify +from hate_crack.notify.settings import NotifySettings + + +@pytest.fixture(autouse=True) +def _reset_notify_state(): + notify.clear_state_for_tests() + yield + notify.clear_state_for_tests() + + +def _install_settings( + *, + enabled: bool = True, + token: str = "tok", + user: str = "usr", +) -> None: + """Swap fresh settings into the notify module for this test.""" + settings = NotifySettings() + settings.enabled = enabled + settings.pushover_token = token + settings.pushover_user = user + notify._settings = settings + + +class TestTestPushoverNotification: + def test_success_prints_confirmation_and_sends(self, capsys): + _install_settings(enabled=True, token="tok", user="usr") + with patch("hate_crack.notify._send_pushover", return_value=True) as send: + hc_main.test_pushover_notification() + assert send.called + args = send.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): + _install_settings(enabled=True, token="tok", user="usr") + with patch("hate_crack.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): + _install_settings(enabled=True, token="", user="usr") + with patch("hate_crack.notify._send_pushover") as send: + hc_main.test_pushover_notification() + send.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): + _install_settings(enabled=True, token="tok", user="") + with patch("hate_crack.notify._send_pushover") as send: + hc_main.test_pushover_notification() + send.assert_not_called() + out = capsys.readouterr().out + assert "[!] Pushover credentials missing" in out + + def test_globally_off_still_sends_with_note(self, capsys): + _install_settings(enabled=False, token="tok", user="usr") + with patch("hate_crack.notify._send_pushover", return_value=True) as send: + hc_main.test_pushover_notification() + send.assert_called_once() + out = capsys.readouterr().out + assert "notifications are globally OFF" in out + assert "[+] Test Pushover notification sent" in out From ce625a7874c951bbdd2a1b6cd1a4f5e1fb538360 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 22 Apr 2026 18:07:52 -0400 Subject: [PATCH 4/7] fix(tests): patch hate_crack.main._notify directly tests/test_random_rules_attack.py purges and re-imports hate_crack.* modules, which leaves main._notify pointing at a different notify object than a top-level patch("hate_crack.notify._send_pushover") would touch. Under the full suite that caused the test's mock to miss and the production call to hit the real Pushover API. Switch to patch.object(hc_main, "_notify") -- same pattern as tests/test_run_hcat_cmd.py but anchored to the exact module object already bound as hc_main, so it is immune to sys.modules churn regardless of import order. Drop the now-redundant _install_settings helper and _reset_notify_state fixture. --- tests/test_ui_test_pushover.py | 80 +++++++++++++++++----------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/tests/test_ui_test_pushover.py b/tests/test_ui_test_pushover.py index 069faee..f5b43b4 100644 --- a/tests/test_ui_test_pushover.py +++ b/tests/test_ui_test_pushover.py @@ -1,41 +1,41 @@ -"""Unit tests for main.test_pushover_notification().""" +"""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 -import pytest - from hate_crack import main as hc_main -from hate_crack import notify -from hate_crack.notify.settings import NotifySettings -@pytest.fixture(autouse=True) -def _reset_notify_state(): - notify.clear_state_for_tests() - yield - notify.clear_state_for_tests() - - -def _install_settings( - *, - enabled: bool = True, - token: str = "tok", - user: str = "usr", -) -> None: - """Swap fresh settings into the notify module for this test.""" - settings = NotifySettings() - settings.enabled = enabled - settings.pushover_token = token - settings.pushover_user = user - notify._settings = settings +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): - _install_settings(enabled=True, token="tok", user="usr") - with patch("hate_crack.notify._send_pushover", return_value=True) as send: + 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() - assert send.called - args = send.call_args.args + 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" @@ -44,34 +44,36 @@ class TestTestPushoverNotification: assert "[+] Test Pushover notification sent" in out def test_failure_prints_failure_line(self, capsys): - _install_settings(enabled=True, token="tok", user="usr") - with patch("hate_crack.notify._send_pushover", return_value=False): + 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): - _install_settings(enabled=True, token="", user="usr") - with patch("hate_crack.notify._send_pushover") as send: + with patch.object(hc_main, "_notify") as mock_notify: + mock_notify.get_settings.return_value = _settings(enabled=True, token="") hc_main.test_pushover_notification() - send.assert_not_called() + 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): - _install_settings(enabled=True, token="tok", user="") - with patch("hate_crack.notify._send_pushover") as send: + with patch.object(hc_main, "_notify") as mock_notify: + mock_notify.get_settings.return_value = _settings(enabled=True, user="") hc_main.test_pushover_notification() - send.assert_not_called() + 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): - _install_settings(enabled=False, token="tok", user="usr") - with patch("hate_crack.notify._send_pushover", return_value=True) as send: + 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() - send.assert_called_once() + 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 From 60b105fed3342c2724cbdedbfc457007b1bce097 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 22 Apr 2026 18:11:35 -0400 Subject: [PATCH 5/7] feat(notify): wire option 84 into main.py menu --- hate_crack/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hate_crack/main.py b/hate_crack/main.py index 9778c39..37f0772 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -4169,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"), @@ -4213,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, From b017b15cb677179d0e7c1c3e1b3826fbb2aaa1f9 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 22 Apr 2026 18:13:31 -0400 Subject: [PATCH 6/7] feat(notify): wire option 84 into hate_crack.py proxy menu --- hate_crack.py | 1 + 1 file changed, 1 insertion(+) 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, From 757cd54a889cbb3e743164c73d212a74990943c0 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 22 Apr 2026 18:15:09 -0400 Subject: [PATCH 7/7] test(notify): cover option 84 in menu wiring parametrize --- tests/test_ui_menu_options.py | 1 + 1 file changed, 1 insertion(+) 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"),