Merge branch 'feat/notifications-submenu' into feat/pushover-notifications

Consolidates Pushover notification menu options under a new submenu
at main-menu option 82, and promotes notify_per_crack_enabled from
config-file-only to a runtime toggle with a UI-level guard that
refuses to enable it while global notifications are OFF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-04-22 20:37:23 -04:00
13 changed files with 1517 additions and 46 deletions

View File

@@ -0,0 +1,876 @@
# Notifications Submenu 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:** Consolidate Pushover notification menu entries under a new main-menu submenu at option `82 — Notifications`, and promote `notify_per_crack_enabled` to a first-class runtime toggle with a UI-level guard that refuses to enable it while the global notification switch is OFF.
**Architecture:** Persist the per-crack flag through the existing atomic-rewrite primitive in `hate_crack/notify/settings.py`; expose a `toggle_per_crack_enabled()` function in the notify module mirroring the existing `toggle_enabled()`; add a new main-menu handler `toggle_per_crack_notifications()` and a submenu dispatcher `notifications_submenu()` to `hate_crack/main.py` following the existing `wordlist_tools_submenu` pattern; update the duplicate menu wiring in `hate_crack.py` per the CLAUDE.md "Adding a New Attack" checklist.
**Tech Stack:** Python 3, existing `hate_crack/menu.py:interactive_menu`, `pytest` + `monkeypatch`, `uv run ruff`, `uv run ty`, `prek` pre-push hooks.
**Worktree:** `/tmp/hate_crack-notifications-submenu` on branch `feat/notifications-submenu` (already created, spec committed as `e165e3d`).
**Spec:** `.claude/specs/2026-04-22-notifications-submenu-design.md`
**Plan location note:** This repo's local `.git/info/exclude` ignores `docs/*`. The project's convention for specs and plans on the `feat/pushover-notifications` branch is `.claude/specs/` and `.claude/plans/`, so this plan lives there alongside `2026-04-22-test-pushover-menu.md`.
**Preflight for every task:**
- All edits happen in `/tmp/hate_crack-notifications-submenu`, never in `/Users/justinbollinger/projects/hate_crack`.
- Prefix every test command with `HATE_CRACK_SKIP_INIT=1` — the worktree has no `hashcat-utils` build and `config.json` loading would otherwise abort.
- Run all commands with `uv run …` so the worktree's own `.venv` is used.
---
## Task 1: Persist `notify_per_crack_enabled` atomically
**Files:**
- Modify: `hate_crack/notify/settings.py` (add a new top-level function after `save_enabled`, around line 148)
- Test: `tests/test_notify_settings.py` (add a new `TestSavePerCrackEnabled` class at the end of the file, after `TestAddToAllowlist`)
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_notify_settings.py`:
```python
class TestSavePerCrackEnabled:
def test_writes_new_config(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
save_per_crack_enabled(str(config_path), True)
data = json.loads(config_path.read_text())
assert data["notify_per_crack_enabled"] is True
def test_preserves_existing_keys(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
initial = {
"hcatBin": "hashcat",
"notify_enabled": True,
"notify_per_crack_enabled": False,
}
config_path.write_text(json.dumps(initial))
save_per_crack_enabled(str(config_path), True)
data = json.loads(config_path.read_text())
assert data["hcatBin"] == "hashcat"
assert data["notify_enabled"] is True
assert data["notify_per_crack_enabled"] is True
def test_toggles_back_and_forth(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
save_per_crack_enabled(str(config_path), True)
save_per_crack_enabled(str(config_path), False)
data = json.loads(config_path.read_text())
assert data["notify_per_crack_enabled"] is False
```
Update the top-level import at the top of `tests/test_notify_settings.py` to include the new function:
```python
from hate_crack.notify.settings import (
NotifySettings,
add_to_allowlist,
load_settings,
save_enabled,
save_per_crack_enabled,
)
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
cd /tmp/hate_crack-notifications-submenu
HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_notify_settings.py::TestSavePerCrackEnabled -v
```
Expected: `ImportError` on collection (symbol does not exist yet).
- [ ] **Step 3: Implement `save_per_crack_enabled`**
Insert in `hate_crack/notify/settings.py` immediately after `save_enabled` (around line 148, before `add_to_allowlist`):
```python
def save_per_crack_enabled(config_path: str, enabled: bool) -> None:
"""Persist ``notify_per_crack_enabled`` without disturbing other config keys."""
def _apply(data: dict) -> None:
data["notify_per_crack_enabled"] = bool(enabled)
_atomic_rewrite(config_path, _apply)
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_notify_settings.py -v
```
Expected: all existing tests still pass, the three new tests in `TestSavePerCrackEnabled` pass.
- [ ] **Step 5: Commit**
```bash
git add hate_crack/notify/settings.py tests/test_notify_settings.py
git commit -m "feat(notify): persist notify_per_crack_enabled atomically"
```
---
## Task 2: Runtime toggle `toggle_per_crack_enabled`
**Files:**
- Modify: `hate_crack/notify/__init__.py` (add to imports, `__all__`, and append a new function after `toggle_enabled` around line 146)
- Create: `tests/test_notify_per_crack_toggle.py`
- [ ] **Step 1: Write the failing tests**
Create `tests/test_notify_per_crack_toggle.py`:
```python
"""Unit tests for the toggle_per_crack_enabled runtime toggle."""
import json
from pathlib import Path
from hate_crack import notify as _notify
def _init_with(tmp_path: Path, **overrides) -> Path:
"""Seed a config file with defaults + overrides and init the notify module."""
config_path = tmp_path / "config.json"
cfg = {
"notify_enabled": False,
"notify_per_crack_enabled": False,
"notify_pushover_token": "",
"notify_pushover_user": "",
}
cfg.update(overrides)
config_path.write_text(json.dumps(cfg))
_notify.init(str(config_path), cfg)
return config_path
class TestTogglePerCrackEnabled:
def test_off_to_on_flips_and_persists(self, tmp_path: Path) -> None:
config_path = _init_with(tmp_path)
try:
new_state = _notify.toggle_per_crack_enabled()
assert new_state is True
assert _notify.get_settings().per_crack_enabled is True
data = json.loads(config_path.read_text())
assert data["notify_per_crack_enabled"] is True
finally:
_notify.clear_state_for_tests()
def test_on_to_off_flips_and_persists(self, tmp_path: Path) -> None:
config_path = _init_with(tmp_path, notify_per_crack_enabled=True)
try:
new_state = _notify.toggle_per_crack_enabled()
assert new_state is False
assert _notify.get_settings().per_crack_enabled is False
data = json.loads(config_path.read_text())
assert data["notify_per_crack_enabled"] is False
finally:
_notify.clear_state_for_tests()
def test_toggle_without_init_uses_defaults(self) -> None:
# Mirrors the behavior of toggle_enabled: must not crash when init
# was never called. The toggle flips an in-memory default; nothing
# is persisted because _config_path is None.
try:
_notify.clear_state_for_tests()
new_state = _notify.toggle_per_crack_enabled()
assert new_state is True
assert _notify.get_settings().per_crack_enabled is True
finally:
_notify.clear_state_for_tests()
def test_does_not_touch_global_enabled(self, tmp_path: Path) -> None:
config_path = _init_with(tmp_path, notify_enabled=False)
try:
_notify.toggle_per_crack_enabled()
data = json.loads(config_path.read_text())
# notify_enabled stays False; only per-crack flipped.
assert data["notify_enabled"] is False
assert data["notify_per_crack_enabled"] is True
finally:
_notify.clear_state_for_tests()
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
cd /tmp/hate_crack-notifications-submenu
HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_notify_per_crack_toggle.py -v
```
Expected: `AttributeError: module 'hate_crack.notify' has no attribute 'toggle_per_crack_enabled'`.
- [ ] **Step 3: Implement `toggle_per_crack_enabled`**
Update the import at the top of `hate_crack/notify/__init__.py` (around line 47) from:
```python
from hate_crack.notify.settings import (
NotifySettings,
add_to_allowlist,
load_settings,
save_enabled,
)
```
to:
```python
from hate_crack.notify.settings import (
NotifySettings,
add_to_allowlist,
load_settings,
save_enabled,
save_per_crack_enabled,
)
```
Add `"toggle_per_crack_enabled"` to `__all__` (around line 77, insert alphabetically — after `"toggle_enabled"`):
```python
__all__ = [
"CrackTailer",
"NotifySettings",
"add_to_allowlist",
"clear_state_for_tests",
"extract_username_from_out_line",
"get_settings",
"init",
"is_suppressed",
"notify_crack",
"notify_job_done",
"prompt_notify_for_attack",
"set_input_func",
"start_tailer",
"stop_tailer",
"suppressed_notifications",
"toggle_enabled",
"toggle_per_crack_enabled",
"_send_pushover",
]
```
Append the new function immediately after `toggle_enabled` (around line 145):
```python
def toggle_per_crack_enabled() -> bool:
"""Flip ``notify_per_crack_enabled``, persist to ``config.json``, return new state.
If ``init`` was never called we still toggle an in-memory default — the
UI update must not crash even if the config file is unreachable.
"""
global _settings
if _settings is None:
_settings = NotifySettings()
_settings.per_crack_enabled = not _settings.per_crack_enabled
if _config_path:
try:
save_per_crack_enabled(_config_path, _settings.per_crack_enabled)
except OSError as exc:
logger.warning("Could not persist notify_per_crack_enabled: %s", exc)
return _settings.per_crack_enabled
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_notify_per_crack_toggle.py tests/test_notify_settings.py tests/test_notify_integration.py -v
```
Expected: all green, including the existing notify integration/settings tests (we have not regressed `toggle_enabled`).
- [ ] **Step 5: Commit**
```bash
git add hate_crack/notify/__init__.py tests/test_notify_per_crack_toggle.py
git commit -m "feat(notify): add toggle_per_crack_enabled runtime toggle"
```
---
## Task 3: UI handler `toggle_per_crack_notifications` with global-OFF guard
**Files:**
- Modify: `hate_crack/main.py` (add a new function immediately after `toggle_notifications`, around line 4109)
- Test: `tests/test_notify_per_crack_toggle.py` (add a new `TestTogglePerCrackNotificationsUI` class)
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_notify_per_crack_toggle.py`:
```python
import importlib.util
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
_CLI_SPEC = importlib.util.spec_from_file_location(
"hate_crack_cli_percrack", PROJECT_ROOT / "hate_crack.py"
)
CLI_MODULE = importlib.util.module_from_spec(_CLI_SPEC)
_CLI_SPEC.loader.exec_module(CLI_MODULE)
class TestTogglePerCrackNotificationsUI:
def _seed_settings(self, monkeypatch, *, enabled: bool, per_crack: bool):
from hate_crack.notify.settings import NotifySettings
settings = NotifySettings(enabled=enabled, per_crack_enabled=per_crack)
monkeypatch.setattr(
CLI_MODULE._notify, "get_settings", lambda: settings
)
return settings
def test_guard_refuses_on_when_global_off(self, monkeypatch, capsys):
self._seed_settings(monkeypatch, enabled=False, per_crack=False)
called = {"n": 0}
def _fake_toggle() -> bool:
called["n"] += 1
return True
monkeypatch.setattr(
CLI_MODULE._notify, "toggle_per_crack_enabled", _fake_toggle
)
CLI_MODULE.toggle_per_crack_notifications()
captured = capsys.readouterr().out
assert "Global Pushover notifications are OFF" in captured
assert called["n"] == 0
def test_flips_on_when_global_on(self, monkeypatch, capsys):
self._seed_settings(monkeypatch, enabled=True, per_crack=False)
called = {"n": 0}
def _fake_toggle() -> bool:
called["n"] += 1
return True
monkeypatch.setattr(
CLI_MODULE._notify, "toggle_per_crack_enabled", _fake_toggle
)
CLI_MODULE.toggle_per_crack_notifications()
captured = capsys.readouterr().out
assert "Per-crack notifications are now ON" in captured
assert called["n"] == 1
def test_off_to_off_is_allowed_even_if_global_off(self, monkeypatch, capsys):
# Turning OFF must always succeed, even with global OFF, so a user
# can clean up an inconsistent (per_crack=True, enabled=False) config.
self._seed_settings(monkeypatch, enabled=False, per_crack=True)
called = {"n": 0}
def _fake_toggle() -> bool:
called["n"] += 1
return False
monkeypatch.setattr(
CLI_MODULE._notify, "toggle_per_crack_enabled", _fake_toggle
)
CLI_MODULE.toggle_per_crack_notifications()
captured = capsys.readouterr().out
assert "Per-crack notifications are now OFF" in captured
assert called["n"] == 1
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_notify_per_crack_toggle.py::TestTogglePerCrackNotificationsUI -v
```
Expected: `AttributeError: module ... has no attribute 'toggle_per_crack_notifications'`.
- [ ] **Step 3: Implement the handler**
Insert in `hate_crack/main.py` immediately after `toggle_notifications` (currently ends around line 4108, just before `def test_pushover_notification():`):
```python
def toggle_per_crack_notifications():
"""Runtime toggle for ``notify_per_crack_enabled`` with a UI-level guard.
Per-crack notifications require global notifications to be ON in order
to fire (see ``notify.start_tailer``). Turning per-crack ON while the
global switch is OFF is silently ineffective, which surprises users —
so we refuse the transition and point them at the global toggle.
Turning per-crack OFF is always allowed, regardless of the global
state, so users can clean up an inconsistent config without friction.
"""
settings = _notify.get_settings()
if not settings.per_crack_enabled and not settings.enabled:
print(
"\n[!] Global Pushover notifications are OFF. Enable option 1 "
"(Toggle Pushover Notifications) first."
)
return
new_state = _notify.toggle_per_crack_enabled()
label = "ON" if new_state else "OFF"
print(f"\nPer-crack notifications are now {label}.")
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_notify_per_crack_toggle.py -v
```
Expected: all `TestTogglePerCrackEnabled` and `TestTogglePerCrackNotificationsUI` tests pass.
- [ ] **Step 5: Commit**
```bash
git add hate_crack/main.py tests/test_notify_per_crack_toggle.py
git commit -m "feat(notify): add per-crack UI toggle with global-OFF guard"
```
---
## Task 4: Submenu dispatcher `notifications_submenu`
**Files:**
- Modify: `hate_crack/main.py` (add a new submenu dispatcher near the other submenu dispatchers, after `rule_tools_submenu` at line 3880)
- Create: `tests/test_notifications_submenu.py`
- [ ] **Step 1: Write the failing tests**
Create `tests/test_notifications_submenu.py`:
```python
"""Unit tests for the Notifications submenu dispatcher (main-menu option 82).
Patching note: ``notifications_submenu`` is defined in ``hate_crack/main.py``
and resolves ``toggle_notifications`` / ``toggle_per_crack_notifications`` /
``test_pushover_notification`` against ``hate_crack.main``'s own globals.
We therefore patch that module directly — patching the ``hate_crack.py``
proxy would have no effect on the submenu's internal dispatch.
"""
import hate_crack.main as _main_mod
import hate_crack.menu as _menu_mod
from hate_crack.notify.settings import NotifySettings
def _stub_action_handlers(monkeypatch, calls):
monkeypatch.setattr(
_main_mod, "toggle_notifications", lambda: calls.append("toggle")
)
monkeypatch.setattr(
_main_mod,
"toggle_per_crack_notifications",
lambda: calls.append("toggle_pc"),
)
monkeypatch.setattr(
_main_mod,
"test_pushover_notification",
lambda: calls.append("test"),
)
def _queue_menu_choices(monkeypatch, choices):
"""Queue ``choices`` as sequential return values from ``interactive_menu``.
Always appends a final ``"99"`` so the loop exits even if the caller
forgot — this mirrors how the real user ends a submenu.
"""
iterator = iter(list(choices) + ["99"])
def _fake_menu(items, title=""):
return next(iterator)
monkeypatch.setattr(_menu_mod, "interactive_menu", _fake_menu)
class TestNotificationsSubmenu:
def test_choice_1_dispatches_toggle_notifications(self, monkeypatch):
calls = []
_stub_action_handlers(monkeypatch, calls)
_queue_menu_choices(monkeypatch, ["1"])
_main_mod.notifications_submenu()
assert calls == ["toggle"]
def test_choice_2_dispatches_toggle_per_crack(self, monkeypatch):
calls = []
_stub_action_handlers(monkeypatch, calls)
_queue_menu_choices(monkeypatch, ["2"])
_main_mod.notifications_submenu()
assert calls == ["toggle_pc"]
def test_choice_3_dispatches_test_pushover(self, monkeypatch):
calls = []
_stub_action_handlers(monkeypatch, calls)
_queue_menu_choices(monkeypatch, ["3"])
_main_mod.notifications_submenu()
assert calls == ["test"]
def test_choice_99_exits_without_dispatch(self, monkeypatch):
calls = []
_stub_action_handlers(monkeypatch, calls)
def _only_99(items, title=""):
return "99"
monkeypatch.setattr(_menu_mod, "interactive_menu", _only_99)
_main_mod.notifications_submenu()
assert calls == []
def test_none_choice_exits_without_dispatch(self, monkeypatch):
calls = []
_stub_action_handlers(monkeypatch, calls)
def _returns_none(items, title=""):
return None
monkeypatch.setattr(_menu_mod, "interactive_menu", _returns_none)
_main_mod.notifications_submenu()
assert calls == []
def test_submenu_labels_reflect_live_settings(self, monkeypatch):
captured_items = {}
def _capture(items, title=""):
captured_items["items"] = items
captured_items["title"] = title
return "99"
monkeypatch.setattr(_menu_mod, "interactive_menu", _capture)
monkeypatch.setattr(
_main_mod._notify,
"get_settings",
lambda: NotifySettings(enabled=True, per_crack_enabled=False),
)
_main_mod.notifications_submenu()
labels = {k: v for k, v in captured_items["items"]}
assert "ON" in labels["1"]
assert "OFF" in labels["2"]
assert labels["3"] == "Send Test Pushover Notification"
assert labels["99"] == "Back to Main Menu"
assert "Notifications" in captured_items["title"]
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_notifications_submenu.py -v
```
Expected: `AttributeError: module ... has no attribute 'notifications_submenu'`.
- [ ] **Step 3: Implement the submenu**
Insert in `hate_crack/main.py` immediately after `rule_tools_submenu` (currently ends at line 3881):
```python
def notifications_submenu():
"""Submenu for all Pushover notification controls (main-menu option 82)."""
from hate_crack.menu import interactive_menu
while True:
settings = _notify.get_settings()
global_label = "ON" if settings.enabled else "OFF"
per_crack_label = "ON" if settings.per_crack_enabled else "OFF"
items = [
("1", f"Toggle Pushover Notifications [{global_label}]"),
("2", f"Toggle Per-Crack Notifications [{per_crack_label}]"),
("3", "Send Test Pushover Notification"),
("99", "Back to Main Menu"),
]
choice = interactive_menu(items, title="\nNotifications:")
if choice is None or choice == "99":
break
if choice == "1":
toggle_notifications()
elif choice == "2":
toggle_per_crack_notifications()
elif choice == "3":
test_pushover_notification()
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_notifications_submenu.py tests/test_notify_per_crack_toggle.py -v
```
Expected: all six tests in `TestNotificationsSubmenu` pass; no regressions in Task 3 tests.
- [ ] **Step 5: Commit**
```bash
git add hate_crack/main.py tests/test_notifications_submenu.py
git commit -m "feat(notify): add Notifications submenu dispatcher"
```
---
## Task 5: Rewire main menu in `main.py` and `hate_crack.py`
**Files:**
- Modify: `hate_crack/main.py` (`get_main_menu_items` around line 4144, `get_main_menu_options` around line 4192)
- Modify: `hate_crack.py` (`get_main_menu_options` around line 74)
- Modify: `tests/test_ui_menu_options.py` (`MENU_OPTION_TEST_CASES` list around line 13)
- [ ] **Step 1: Update the parametrized menu test cases**
Edit `tests/test_ui_menu_options.py`. Replace the two rows for options `83` and `84` (lines 3536 as of now) with a single row for option `82`:
Find:
```python
("83", CLI_MODULE, "toggle_notifications", "toggle-notifications"),
("84", CLI_MODULE, "test_pushover_notification", "test-pushover"),
```
Replace with:
```python
("82", CLI_MODULE, "notifications_submenu", "notifications-submenu"),
```
Then add the following regression test to the same file, immediately after `test_main_menu_option_94_hashview_visible_with_hashview_api_key`:
```python
def test_main_menu_no_longer_exposes_options_83_84():
"""Options 83 and 84 moved into the Notifications submenu (option 82)."""
options = CLI_MODULE.get_main_menu_options()
assert "83" not in options
assert "84" not in options
assert "82" in options
def test_main_menu_items_include_notifications_entry():
items = dict(CLI_MODULE.get_main_menu_items())
assert "82" in items
assert "Notifications" in items["82"]
assert "83" not in items
assert "84" not in items
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_ui_menu_options.py -v
```
Expected: the parametrized `("82", …)` case fails with "Menu option 82 must exist", and the two new regression tests fail.
- [ ] **Step 3: Update `hate_crack/main.py`**
Edit `get_main_menu_items` (current definition starts at line 4144). Replace the block:
```python
("80", "Wordlist Tools"),
("81", "Rule File Tools"),
(
"83",
f"Toggle Pushover Notifications [{'ON' if _notify.get_settings().enabled else 'OFF'}]",
),
("84", "Send Test Pushover Notification"),
```
with:
```python
("80", "Wordlist Tools"),
("81", "Rule File Tools"),
("82", "Notifications"),
```
Edit `get_main_menu_options` (current definition starts at line 4192). Replace the block:
```python
"80": wordlist_tools_submenu,
"81": rule_tools_submenu,
"83": toggle_notifications,
"84": test_pushover_notification,
```
with:
```python
"80": wordlist_tools_submenu,
"81": rule_tools_submenu,
"82": notifications_submenu,
```
- [ ] **Step 4: Update `hate_crack.py`**
Edit `get_main_menu_options` (starts around line 74). Replace the block:
```python
"80": _attacks.wordlist_tools_submenu,
"81": _attacks.rule_tools_submenu,
"83": toggle_notifications,
"84": test_pushover_notification,
```
with:
```python
"80": _attacks.wordlist_tools_submenu,
"81": _attacks.rule_tools_submenu,
"82": notifications_submenu,
```
The `notifications_submenu` name resolves automatically through the existing `__getattr__` proxy at `hate_crack.py:20-21`, so no explicit import is required.
- [ ] **Step 5: Run full UI and notify test suites**
```bash
HATE_CRACK_SKIP_INIT=1 uv run pytest \
tests/test_ui_menu_options.py \
tests/test_notifications_submenu.py \
tests/test_notify_per_crack_toggle.py \
tests/test_notify_settings.py \
tests/test_notify_integration.py \
-v
```
Expected: every test passes, including the parametrized `82` case and the two regression tests.
- [ ] **Step 6: Commit**
```bash
git add hate_crack/main.py hate_crack.py tests/test_ui_menu_options.py
git commit -m "feat(notify): move options 83/84 under new Notifications submenu (82)"
```
---
## Task 6: Documentation updates
**Files:**
- Modify: `README.md` (add a new "Notifications (menu option 82)" section, modeled on the existing "Wordlist Tools (menu option 80)" section at line 475)
- Modify: `.claude/specs/2026-04-22-test-pushover-menu-design.md` (add a supersede note at the top)
- [ ] **Step 1: Add a README section**
Open `README.md`. The existing sections under `## Usage` include `### Wordlist Tools (menu option 80)` (around line 475). Insert a new section **immediately before** the Wordlist Tools section so the numbered menu options read top-to-bottom:
```markdown
### Notifications (menu option 82)
hate_crack can send Pushover push notifications when attacks complete and,
optionally, when individual hashes are cracked. All controls live under
main-menu option `82 — Notifications`:
1. **Toggle Pushover Notifications [ON/OFF]** — master switch. Persists to `config.json` as `notify_enabled`.
2. **Toggle Per-Crack Notifications [ON/OFF]** — when ON, a background tailer watches the `.out` file and pushes a notification per crack (with per-tick burst aggregation). Persists to `config.json` as `notify_per_crack_enabled`. Cannot be enabled while the master switch is OFF — enable option 1 first.
3. **Send Test Pushover Notification** — fires a canned push so you can confirm your Pushover token/user pair works. Works even when the master switch is OFF.
Credentials and tuning knobs remain config-file-only in `config.json`:
- `notify_pushover_token`, `notify_pushover_user` — required for any push to fire.
- `notify_attack_allowlist` — attack names that auto-consent without the `[y/N/always]` prompt. Populated automatically when you answer `always`.
- `notify_suppress_in_orchestrators` (default `true`) — silences nested attacks launched by Quick/Extensive/Brute-Force wrappers; the wrapper fires a single summary instead.
- `notify_max_cracks_per_burst` (default `5`), `notify_poll_interval_seconds` (default `5.0`) — per-crack tailer tuning. See `hate_crack/notify/tailer.py` for the burst aggregation logic.
```
- [ ] **Step 2: Add a supersede note to the old spec**
Open `.claude/specs/2026-04-22-test-pushover-menu-design.md`. Insert at the very top, before the first line:
```markdown
> **Superseded by** `.claude/specs/2026-04-22-notifications-submenu-design.md`.
> The top-level option 83/84 layout described here was replaced by a single
> main-menu entry at option 82 that opens a Notifications submenu.
```
- [ ] **Step 3: Verify markdown renders cleanly**
No test framework for markdown; a visual sanity check is enough. Run:
```bash
grep -n "menu option 82\|menu option 80" README.md
```
Expected: both sections appear, 82 before 80 in line order (so the final rendered doc reads 80 → 81 → 82 once we include 81… but 81 has no section in the current README, so the expected visible order is `82`-section, then `80`-section — which is fine; the README's submenu sections are not strictly ordered by option number today).
- [ ] **Step 4: Commit**
```bash
git add README.md .claude/specs/2026-04-22-test-pushover-menu-design.md
git commit -m "docs: document Notifications submenu (option 82) in README"
```
---
## Task 7: Final verification (lint, full test suite)
**Files:** none (verification only)
- [ ] **Step 1: Run ruff and ty**
```bash
cd /tmp/hate_crack-notifications-submenu
uv run ruff check hate_crack
uv run ty check hate_crack
```
Expected: zero errors from each. If `ruff` reports issues in the new code, auto-fix with `uv run ruff check --fix hate_crack` and re-run.
- [ ] **Step 2: Run ruff format (non-destructive check)**
```bash
uv run ruff format --check hate_crack
```
Expected: zero reformatting diffs. If diffs exist, apply with `uv run ruff format hate_crack` and commit as a separate `style:` commit.
- [ ] **Step 3: Run the full test suite**
```bash
HATE_CRACK_SKIP_INIT=1 uv run pytest -v
```
Expected: all tests pass. Pay attention to any pre-existing `xfail`/`skip` markers — they should remain unchanged.
- [ ] **Step 4: Manual smoke check of menu wiring (optional but recommended)**
```bash
HATE_CRACK_SKIP_INIT=1 uv run python - <<'PY'
import hate_crack as hc
items = hc.get_main_menu_items()
options = hc.get_main_menu_options()
print("items:")
for k, v in items:
if k in ("80", "81", "82", "83", "84"):
print(f" {k}: {v}")
print("options keys for 80-84:", [k for k in options if k in ("80", "81", "82", "83", "84")])
PY
```
Expected output:
```
items:
80: Wordlist Tools
81: Rule File Tools
82: Notifications
options keys for 80-84: ['80', '81', '82']
```
- [ ] **Step 5: Commit any lint/format fixes if there were any**
If Steps 1 or 2 produced a diff:
```bash
git add -u
git commit -m "style: ruff format pass for Notifications submenu"
```
Otherwise skip.
- [ ] **Step 6: Summarize the branch state**
```bash
git log --oneline feat/pushover-notifications..HEAD
```
Expected: six to seven commits, one per task (plus an optional style commit).
---
## Out of Scope
- Interactive editors for `notify_max_cracks_per_burst`, `notify_poll_interval_seconds`, or `notify_attack_allowlist` — these remain config-file-only.
- A "Show current notification settings" read-only submenu item.
- Any change to `start_tailer`, the Pushover HTTP backend, or the orchestrator suppression logic.
- Renaming or reorganizing existing tests beyond the parametrized `MENU_OPTION_TEST_CASES` list.

View File

@@ -0,0 +1,137 @@
# Notifications Submenu Design
**Date:** 2026-04-22
**Branch:** `feat/notifications-submenu` (forked from `feat/pushover-notifications`)
**Status:** Approved — ready for implementation planning
**Supersedes (in part):** `.claude/specs/2026-04-22-test-pushover-menu-design.md` (the top-level option 83/84 layout described there is replaced by this submenu)
## Motivation
The Pushover notification feature currently surfaces two top-level options in the main menu — `83) Toggle Pushover Notifications [ON/OFF]` and `84) Send Test Pushover Notification`. The `notify_per_crack_enabled` setting, which controls whether hashcat's `.out` file is tailed for live per-crack pings, is only reachable by editing `config.json` by hand.
Two goals:
1. Promote the per-crack setting to a first-class runtime toggle so users can flip it without leaving the program.
2. Consolidate notification controls under a single main-menu entry (option `82`), matching the existing `Wordlist Tools` / `Rule File Tools` submenu pattern, so the main menu stays short as more notification features are added.
## Scope
**In scope:**
- New main-menu option `82) Notifications` that opens a submenu.
- Submenu items:
1. `Toggle Pushover Notifications [ON/OFF]` — existing behavior, relocated.
2. `Toggle Per-Crack Notifications [ON/OFF]` — NEW.
3. `Send Test Pushover Notification` — existing behavior, relocated.
4. `99) Back to Main Menu`.
- Remove standalone options `83` and `84` from the main menu.
- New `toggle_per_crack_enabled()` in the notify module with atomic config persistence.
- Guard: refuse to turn per-crack ON while global notifications are OFF.
- Test coverage for the new toggle, guard behavior, and menu wiring.
- Update README and any in-tree docs that reference option 83/84.
**Out of scope:**
- Interactive editors for `notify_max_cracks_per_burst`, `notify_poll_interval_seconds`, `notify_attack_allowlist`, or Pushover credentials — these remain config-file-only for now.
- A "Show current notification settings" read-only view.
- Any change to how the per-crack tailer actually runs.
## Design
### Menu layout
`hate_crack/main.py:get_main_menu_items` and the duplicate in `hate_crack.py:get_main_menu_options`:
- **Add** `("82", "Notifications")` between `("81", "Rule File Tools")` and the `90+` range.
- **Remove** the two live-labeled entries for `83` and `84` and their corresponding entries in `get_main_menu_options`.
New submenu rendered by `notifications_submenu()` in `hate_crack/main.py`, using the existing `interactive_menu` helper and the same item-list pattern as `wordlist_tools_submenu` (`main.py:3860` dispatcher, `attacks.py:1171` implementation):
```
Notifications:
1) Toggle Pushover Notifications [ON/OFF]
2) Toggle Per-Crack Notifications [ON/OFF]
3) Send Test Pushover Notification
99) Back to Main Menu
```
Both `[ON/OFF]` labels are recomputed from `_notify.get_settings()` on each render, matching the live-label pattern already used for option 83 (`main.py:4170`).
### Toggle behavior
**`hate_crack/notify/settings.py`** — add `save_per_crack_enabled(config_path: str, enabled: bool) -> None`:
Mirrors `save_enabled` at `settings.py:141`. Uses the existing `_atomic_rewrite` primitive to write `notify_per_crack_enabled` without disturbing other keys. Single-line mutator.
**`hate_crack/notify/__init__.py`** — add `toggle_per_crack_enabled() -> bool`:
Mirrors `toggle_enabled` at `__init__.py:130`:
- Flips `_settings.per_crack_enabled`.
- Persists via `save_per_crack_enabled(_config_path, …)`.
- Returns the new bool.
- Logs `OSError` at `warning` level on persistence failure (same pattern as `toggle_enabled`).
- Exported via `__all__`.
The notify module does *not* enforce the "global must be ON" rule — the runtime logic at `start_tailer` (`__init__.py:264-272`) already correctly handles any combination of flags (per-crack only fires when both are true). The "global must be ON" rule is a *UI affordance*, not a data invariant, so it belongs in the menu handler.
**`hate_crack/main.py`** — add `toggle_per_crack_notifications()`:
```
settings = _notify.get_settings()
if not settings.per_crack_enabled and not settings.enabled:
print(
"\n[!] Global Pushover notifications are OFF. Enable option 1 "
"(Toggle Pushover Notifications) first."
)
return
new_state = _notify.toggle_per_crack_enabled()
label = "ON" if new_state else "OFF"
print(f"\nPer-crack notifications are now {label}.")
```
Turning OFF is always allowed (the guard only fires when currently OFF and being asked to turn ON while the global is OFF). This lets users clean up inconsistent `notify_per_crack_enabled: true, notify_enabled: false` configs without surprise.
**`hate_crack/main.py`** — add `notifications_submenu()`:
Follows the `wordlist_tools_submenu` dispatcher pattern at `main.py:3860`. Holds an ordered item list, loops on `interactive_menu`, breaks on `None` or `"99"`, dispatches to `toggle_notifications`, `toggle_per_crack_notifications`, `test_pushover_notification`.
Labels for items 1 and 2 are recomputed inside the loop so they refresh after each toggle.
### Proxy shim
`hate_crack.py` auto-proxies attribute access to `hate_crack.main` via `__getattr__` (`hate_crack.py:20-21`), so `notifications_submenu` and `toggle_per_crack_notifications` will be reachable through the proxy without explicit re-export. The duplicate `get_main_menu_options` in `hate_crack.py` still needs the same `"82"``notifications_submenu` entry and removal of `"83"` / `"84"`, per the CLAUDE.md "Adding a New Attack" checklist (which also applies to menu changes).
## Testing
**New file `tests/test_notify_per_crack_toggle.py`:**
- `save_per_crack_enabled` round-trips True/False through a temp config, preserving other keys.
- `toggle_per_crack_enabled` flips in-memory state and persists to the config path passed to `_notify.init`.
- State survives a second `load_settings` call (verifies persistence is real, not just in-memory).
**Additions to `tests/test_notify_settings.py`:**
- A row asserting that `save_per_crack_enabled` writes the expected key, similar to the existing `save_enabled` coverage.
**Additions to `tests/test_ui_menu_options.py`:**
- `"82"` dispatches to the notifications submenu.
- `"83"` and `"84"` no longer appear in `get_main_menu_options()` / `get_main_menu_items()` — both for `main.py` directly and for `CLI_MODULE` (the `hate_crack.py` proxy).
- Submenu: choosing `"1"` calls `toggle_notifications`; `"2"` calls `toggle_per_crack_notifications`; `"3"` calls `test_pushover_notification`; `"99"` exits.
- Guard: when global is OFF and per-crack is OFF, selecting submenu `"2"` prints the refusal message and does NOT call `_notify.toggle_per_crack_enabled`.
- Guard: when global is OFF but per-crack is currently ON, selecting submenu `"2"` DOES flip (turning off is unrestricted).
All tests use monkeypatching against `CLI_MODULE` where the existing menu tests do, and against `_notify` directly where the underlying module behavior is under test.
## Documentation
- `README.md`: if it currently references option `83`/`84` or describes Pushover setup, update those references to point at submenu `82` and document the new per-crack toggle. If the README is silent on Pushover, do not invent a new section — keep this change minimal.
- `hate_crack/notify/__init__.py` module docstring: if it references menu wiring, update it; otherwise leave.
- `.claude/specs/2026-04-22-test-pushover-menu-design.md`: add a short "Superseded by …" header pointing at this spec; do not rewrite — it's history.
- Any in-tree comment that mentions option `83`/`84` by number.
## Execution
- Worktree: `/tmp/hate_crack-notifications-submenu`, branch `feat/notifications-submenu`, forked from `feat/pushover-notifications` tip (`7842f63`).
- Implementation uses subagent-driven development per user's global CLAUDE.md.
- All edits, tests, and lint runs happen inside the worktree.
- Merge back to `feat/pushover-notifications` via PR or `git merge` once all tests and lint pass.

View File

@@ -1,3 +1,7 @@
> **Superseded by** `.claude/specs/2026-04-22-notifications-submenu-design.md`.
> The top-level option 83/84 layout described here was replaced by a single
> main-menu entry at option 82 that opens a Notifications submenu.
# Test Pushover Notification — Menu Entry
**Date:** 2026-04-22

9
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1,9 @@
# Revisions listed here are skipped by `git blame --ignore-revs-file`.
# Enable locally with:
# git config blame.ignoreRevsFile .git-blame-ignore-revs
#
# Only add commits whose changes are purely mechanical (e.g., formatter
# passes, mass-rename refactors). Do not add feature commits.
# style: ruff format pass for Notifications submenu
9b684bb44c3cd04b00a5421691179ea56162780c

View File

@@ -472,6 +472,23 @@ The LLM Attack (option 15) uses Ollama to generate password candidates. Configur
- **`ollamaNumCtx`** — Context window size for the model (default: `2048`).
- The Ollama URL defaults to `http://localhost:11434`. Ensure Ollama is running before using the LLM Attack.
### Notifications (menu option 82)
hate_crack can send Pushover push notifications when attacks complete and,
optionally, when individual hashes are cracked. All controls live under
main-menu option `82 — Notifications`:
1. **Toggle Pushover Notifications [ON/OFF]** — master switch. Persists to `config.json` as `notify_enabled`.
2. **Toggle Per-Crack Notifications [ON/OFF]** — when ON, a background tailer watches the `.out` file and pushes a notification per crack (with per-tick burst aggregation). Persists to `config.json` as `notify_per_crack_enabled`. Cannot be enabled while the master switch is OFF — enable option 1 first.
3. **Send Test Pushover Notification** — fires a canned push so you can confirm your Pushover token/user pair works. Works even when the master switch is OFF.
Credentials and tuning knobs remain config-file-only in `config.json`:
- `notify_pushover_token`, `notify_pushover_user` — required for any push to fire.
- `notify_attack_allowlist` — attack names that auto-consent without the `[y/N/always]` prompt. Populated automatically when you answer `always`.
- `notify_suppress_in_orchestrators` (default `true`) — silences nested attacks launched by Quick/Extensive/Brute-Force wrappers; the wrapper fires a single summary instead.
- `notify_max_cracks_per_burst` (default `5`), `notify_poll_interval_seconds` (default `5.0`) — per-crack tailer tuning. See `hate_crack/notify/tailer.py` for the burst aggregation logic.
### Wordlist Tools (menu option 80)
The Wordlist Tools submenu provides 7 wordlist preprocessing utilities backed by hashcat-utils binaries. Access via option **80** in the main menu.

View File

@@ -94,8 +94,7 @@ def get_main_menu_options():
"22": _attacks.combipow_crack,
"80": _attacks.wordlist_tools_submenu,
"81": _attacks.rule_tools_submenu,
"83": toggle_notifications,
"84": test_pushover_notification,
"82": notifications_submenu,
"90": download_hashmob_rules,
"91": weakpass_wordlist_menu,
"92": download_hashmob_wordlists,

View File

@@ -401,7 +401,9 @@ if hcatOptimizedWordlists:
if not os.path.isdir(hcatOptimizedWordlists):
fallback_optimized = os.path.join(hate_path, "optimized_wordlists")
if os.path.isdir(fallback_optimized):
print(f"[!] hcatOptimizedWordlists directory not found: {hcatOptimizedWordlists}")
print(
f"[!] hcatOptimizedWordlists directory not found: {hcatOptimizedWordlists}"
)
print(f"[!] Falling back to {fallback_optimized}")
hcatOptimizedWordlists = fallback_optimized
else:
@@ -417,8 +419,12 @@ pipalPath = config_parser["pipalPath"]
hcatDictionaryWordlist = config_parser["hcatDictionaryWordlist"]
hcatHybridlist = config_parser["hcatHybridlist"]
hcatCombinationWordlist = config_parser["hcatCombinationWordlist"]
hcatCombinator3Wordlist = config_parser.get("hcatCombinator3Wordlist", ["rockyou.txt", "rockyou.txt", "rockyou.txt"])
hcatCombinatorXWordlist = config_parser.get("hcatCombinatorXWordlist", ["rockyou.txt", "rockyou.txt"])
hcatCombinator3Wordlist = config_parser.get(
"hcatCombinator3Wordlist", ["rockyou.txt", "rockyou.txt", "rockyou.txt"]
)
hcatCombinatorXWordlist = config_parser.get(
"hcatCombinatorXWordlist", ["rockyou.txt", "rockyou.txt"]
)
hcatMiddleCombinatorMasks = config_parser["hcatMiddleCombinatorMasks"]
hcatMiddleBaseList = config_parser["hcatMiddleBaseList"]
hcatThoroughCombinatorMasks = config_parser["hcatThoroughCombinatorMasks"]
@@ -584,8 +590,12 @@ hcatDictionaryWordlist = _normalize_wordlist_setting(
hcatCombinationWordlist = _normalize_wordlist_setting(
hcatCombinationWordlist, wordlists_dir
)
hcatCombinator3Wordlist = _normalize_wordlist_setting(hcatCombinator3Wordlist, wordlists_dir)
hcatCombinatorXWordlist = _normalize_wordlist_setting(hcatCombinatorXWordlist, wordlists_dir)
hcatCombinator3Wordlist = _normalize_wordlist_setting(
hcatCombinator3Wordlist, wordlists_dir
)
hcatCombinatorXWordlist = _normalize_wordlist_setting(
hcatCombinatorXWordlist, wordlists_dir
)
hcatHybridlist = _normalize_wordlist_setting(hcatHybridlist, wordlists_dir)
hcatMiddleBaseList = _normalize_wordlist_setting(hcatMiddleBaseList, wordlists_dir)
hcatThoroughBaseList = _normalize_wordlist_setting(hcatThoroughBaseList, wordlists_dir)
@@ -2397,8 +2407,14 @@ def hcatMarkovTrain(source_file, hcatHashFile):
hcatProcess.wait(timeout=300)
if hcatProcess.returncode != 0:
_, stderr_data = hcatProcess.communicate()
err_msg = stderr_data.decode("utf-8", errors="replace") if stderr_data else "Unknown error"
print(f"[!] hcstat2gen.bin failed with code {hcatProcess.returncode}: {err_msg}")
err_msg = (
stderr_data.decode("utf-8", errors="replace")
if stderr_data
else "Unknown error"
)
print(
f"[!] hcstat2gen.bin failed with code {hcatProcess.returncode}: {err_msg}"
)
return False
except subprocess.TimeoutExpired:
print("[!] hcstat2gen.bin timed out after 300 seconds")
@@ -3011,7 +3027,9 @@ def cleanup():
if os.path.isfile(out_path):
print(f"\nCracked passwords combined with original hashes in {out_path}")
else:
print(f"\nNo cracked hashes to combine. Raw output (if any): {hcatHashFile}.out")
print(
f"\nNo cracked hashes to combine. Raw output (if any): {hcatHashFile}.out"
)
print("\nCleaning up temporary files...")
if os.path.exists(hcatHashFile + ".masks"):
os.remove(hcatHashFile + ".masks")
@@ -3813,9 +3831,7 @@ def wordlist_filter_req_exclude(infile: str, outfile: str, mask: int) -> bool:
return result.returncode == 0
def wordlist_cutb(
infile: str, outfile: str, offset: int, length: int | None
) -> bool:
def wordlist_cutb(infile: str, outfile: str, offset: int, length: int | None) -> bool:
"""Extract a substring from each word starting at offset, optionally limited to length bytes."""
cutb_bin = os.path.join(hate_path, "hashcat-utils/bin/cutb.bin")
cmd = [cutb_bin, str(offset)]
@@ -3853,7 +3869,9 @@ def wordlist_gate(infile: str, outfile: str, mod: int, offset: int) -> bool:
"""Shard wordlist: keep every mod-th line starting at offset."""
gate_bin = os.path.join(hate_path, "hashcat-utils/bin/gate.bin")
with open(infile, "rb") as fin, open(outfile, "wb") as fout:
result = subprocess.run([gate_bin, str(mod), str(offset)], stdin=fin, stdout=fout)
result = subprocess.run(
[gate_bin, str(mod), str(offset)], stdin=fin, stdout=fout
)
return result.returncode == 0
@@ -3871,7 +3889,9 @@ def rules_cleanup(infile: str, outfile: str) -> bool:
def rules_optimize(infile: str, outfile: str) -> bool:
"""Optimize a rule file using rules_optimize.bin. Returns True on success."""
optimize_path = os.path.join(hate_path, "hashcat-utils", "bin", "rules_optimize.bin")
optimize_path = os.path.join(
hate_path, "hashcat-utils", "bin", "rules_optimize.bin"
)
with open(infile, "rb") as fin, open(outfile, "wb") as fout:
result = subprocess.run([optimize_path], stdin=fin, stdout=fout)
return result.returncode == 0
@@ -3881,6 +3901,39 @@ def rule_tools_submenu():
return _attacks.rule_tools_submenu(_attack_ctx())
def notifications_submenu():
"""Submenu for all Pushover notification controls (main-menu option 82).
The inline ``interactive_menu`` import is not redundant with the
module-scope import at the top of this file: re-importing inside the
function re-reads ``hate_crack.menu.interactive_menu`` on every call,
which lets tests patch the real menu function via
``monkeypatch.setattr(hate_crack.menu, "interactive_menu", ...)``.
Removing it breaks test isolation.
"""
from hate_crack.menu import interactive_menu
while True:
settings = _notify.get_settings()
global_label = "ON" if settings.enabled else "OFF"
per_crack_label = "ON" if settings.per_crack_enabled else "OFF"
items = [
("1", f"Toggle Pushover Notifications [{global_label}]"),
("2", f"Toggle Per-Crack Notifications [{per_crack_label}]"),
("3", "Send Test Pushover Notification"),
("99", "Back to Main Menu"),
]
choice = interactive_menu(items, title="\nNotifications:")
if choice is None or choice == "99":
break
if choice == "1":
toggle_notifications()
elif choice == "2":
toggle_per_crack_notifications()
elif choice == "3":
test_pushover_notification()
# convert hex words for recycling
def convert_hex(working_file):
processed_words = []
@@ -4108,6 +4161,29 @@ def toggle_notifications():
)
def toggle_per_crack_notifications():
"""Runtime toggle for ``notify_per_crack_enabled`` with a UI-level guard.
Per-crack notifications require global notifications to be ON in order
to fire (see ``notify.start_tailer``). Turning per-crack ON while the
global switch is OFF is silently ineffective, which surprises users —
so we refuse the transition and point them at the global toggle.
Turning per-crack OFF is always allowed, regardless of the global
state, so users can clean up an inconsistent config without friction.
"""
settings = _notify.get_settings()
if not settings.per_crack_enabled and not settings.enabled:
print(
"\n[!] Global Pushover notifications are OFF. Enable option 1 "
"(Toggle Pushover Notifications) first."
)
return
new_state = _notify.toggle_per_crack_enabled()
label = "ON" if new_state else "OFF"
print(f"\nPer-crack notifications are now {label}.")
def test_pushover_notification():
"""Send a canned test notification so the user can verify Pushover works.
@@ -4165,11 +4241,7 @@ def get_main_menu_items():
("22", "Combipow Passphrase Attack"),
("80", "Wordlist Tools"),
("81", "Rule File Tools"),
(
"83",
f"Toggle Pushover Notifications [{'ON' if _notify.get_settings().enabled else 'OFF'}]",
),
("84", "Send Test Pushover Notification"),
("82", "Notifications"),
("90", "Download rules from Hashmob.net"),
("91", "Analyze Hashcat Rules"),
("92", "Download wordlists from Hashmob.net"),
@@ -4213,8 +4285,7 @@ def get_main_menu_options():
"22": combipow_crack,
"80": wordlist_tools_submenu,
"81": rule_tools_submenu,
"83": toggle_notifications,
"84": test_pushover_notification,
"82": notifications_submenu,
"90": lambda: download_hashmob_rules(rules_dir=rulesDirectory),
"91": analyze_rules,
"92": download_hashmob_wordlists,

View File

@@ -49,6 +49,7 @@ from hate_crack.notify.settings import (
add_to_allowlist,
load_settings,
save_enabled,
save_per_crack_enabled,
)
from hate_crack.notify.tailer import (
CrackTailer,
@@ -91,6 +92,7 @@ __all__ = [
"stop_tailer",
"suppressed_notifications",
"toggle_enabled",
"toggle_per_crack_enabled",
"_send_pushover",
]
@@ -145,6 +147,24 @@ def toggle_enabled() -> bool:
return _settings.enabled
def toggle_per_crack_enabled() -> bool:
"""Flip ``notify_per_crack_enabled``, persist to ``config.json``, return new state.
If ``init`` was never called we still toggle an in-memory default — the
UI update must not crash even if the config file is unreachable.
"""
global _settings
if _settings is None:
_settings = NotifySettings()
_settings.per_crack_enabled = not _settings.per_crack_enabled
if _config_path:
try:
save_per_crack_enabled(_config_path, _settings.per_crack_enabled)
except OSError as exc:
logger.warning("Could not persist notify_per_crack_enabled: %s", exc)
return _settings.per_crack_enabled
def _in_allowlist(attack_name: str) -> bool:
return attack_name in get_settings().attack_allowlist

View File

@@ -84,8 +84,12 @@ def load_settings(config_parser: dict | None) -> NotifySettings:
defaults = NotifySettings()
return NotifySettings(
enabled=_coerce_bool(cfg.get("notify_enabled"), defaults.enabled),
pushover_token=_coerce_str(cfg.get("notify_pushover_token"), defaults.pushover_token),
pushover_user=_coerce_str(cfg.get("notify_pushover_user"), defaults.pushover_user),
pushover_token=_coerce_str(
cfg.get("notify_pushover_token"), defaults.pushover_token
),
pushover_user=_coerce_str(
cfg.get("notify_pushover_user"), defaults.pushover_user
),
per_crack_enabled=_coerce_bool(
cfg.get("notify_per_crack_enabled"), defaults.per_crack_enabled
),
@@ -147,6 +151,15 @@ def save_enabled(config_path: str, enabled: bool) -> None:
_atomic_rewrite(config_path, _apply)
def save_per_crack_enabled(config_path: str, enabled: bool) -> None:
"""Persist ``notify_per_crack_enabled`` without disturbing other config keys."""
def _apply(data: dict) -> None:
data["notify_per_crack_enabled"] = bool(enabled)
_atomic_rewrite(config_path, _apply)
def add_to_allowlist(config_path: str, attack_name: str) -> None:
"""Append ``attack_name`` to ``notify_attack_allowlist`` if absent.

View File

@@ -0,0 +1,136 @@
"""Unit tests for the Notifications submenu dispatcher (main-menu option 82).
Patching note: ``notifications_submenu`` is defined in ``hate_crack/main.py``
and resolves ``toggle_notifications`` / ``toggle_per_crack_notifications`` /
``test_pushover_notification`` against ``hate_crack.main``'s own globals.
We therefore patch that module directly — patching the ``hate_crack.py``
proxy would have no effect on the submenu's internal dispatch.
"""
import hate_crack.main as _main_mod
import hate_crack.menu as _menu_mod
from hate_crack.notify.settings import NotifySettings
def _stub_action_handlers(monkeypatch, calls):
monkeypatch.setattr(
_main_mod, "toggle_notifications", lambda: calls.append("toggle")
)
monkeypatch.setattr(
_main_mod,
"toggle_per_crack_notifications",
lambda: calls.append("toggle_pc"),
)
monkeypatch.setattr(
_main_mod,
"test_pushover_notification",
lambda: calls.append("test"),
)
def _queue_menu_choices(monkeypatch, choices):
"""Queue ``choices`` as sequential return values from ``interactive_menu``.
Always appends a final ``"99"`` so the loop exits even if the caller
forgot — this mirrors how the real user ends a submenu.
"""
iterator = iter(list(choices) + ["99"])
def _fake_menu(items, title=""):
return next(iterator)
monkeypatch.setattr(_menu_mod, "interactive_menu", _fake_menu)
class TestNotificationsSubmenu:
def test_choice_1_dispatches_toggle_notifications(self, monkeypatch):
calls = []
_stub_action_handlers(monkeypatch, calls)
_queue_menu_choices(monkeypatch, ["1"])
_main_mod.notifications_submenu()
assert calls == ["toggle"]
def test_choice_2_dispatches_toggle_per_crack(self, monkeypatch):
calls = []
_stub_action_handlers(monkeypatch, calls)
_queue_menu_choices(monkeypatch, ["2"])
_main_mod.notifications_submenu()
assert calls == ["toggle_pc"]
def test_choice_3_dispatches_test_pushover(self, monkeypatch):
calls = []
_stub_action_handlers(monkeypatch, calls)
_queue_menu_choices(monkeypatch, ["3"])
_main_mod.notifications_submenu()
assert calls == ["test"]
def test_choice_99_exits_without_dispatch(self, monkeypatch):
calls = []
_stub_action_handlers(monkeypatch, calls)
def _only_99(items, title=""):
return "99"
monkeypatch.setattr(_menu_mod, "interactive_menu", _only_99)
_main_mod.notifications_submenu()
assert calls == []
def test_none_choice_exits_without_dispatch(self, monkeypatch):
calls = []
_stub_action_handlers(monkeypatch, calls)
def _returns_none(items, title=""):
return None
monkeypatch.setattr(_menu_mod, "interactive_menu", _returns_none)
_main_mod.notifications_submenu()
assert calls == []
def test_submenu_labels_reflect_live_settings(self, monkeypatch):
captured_items = {}
def _capture(items, title=""):
captured_items["items"] = items
captured_items["title"] = title
return "99"
monkeypatch.setattr(_menu_mod, "interactive_menu", _capture)
monkeypatch.setattr(
_main_mod._notify,
"get_settings",
lambda: NotifySettings(enabled=True, per_crack_enabled=False),
)
_main_mod.notifications_submenu()
labels = {k: v for k, v in captured_items["items"]}
assert "ON" in labels["1"]
assert "OFF" in labels["2"]
assert labels["3"] == "Send Test Pushover Notification"
assert labels["99"] == "Back to Main Menu"
assert "Notifications" in captured_items["title"]
def test_labels_refresh_between_iterations(self, monkeypatch):
# Guards against a regression where items are built once outside
# the while-loop: labels would go stale after a toggle.
settings = NotifySettings(enabled=False, per_crack_enabled=False)
monkeypatch.setattr(_main_mod._notify, "get_settings", lambda: settings)
def _flip_enabled():
settings.enabled = not settings.enabled
monkeypatch.setattr(_main_mod, "toggle_notifications", _flip_enabled)
monkeypatch.setattr(_main_mod, "toggle_per_crack_notifications", lambda: None)
monkeypatch.setattr(_main_mod, "test_pushover_notification", lambda: None)
captured = []
choices = iter(["1", "99"])
def _fake_menu(items, title=""):
captured.append(dict(items))
return next(choices)
monkeypatch.setattr(_menu_mod, "interactive_menu", _fake_menu)
_main_mod.notifications_submenu()
assert len(captured) == 2
assert "[OFF]" in captured[0]["1"]
assert "[ON]" in captured[1]["1"]

View File

@@ -0,0 +1,135 @@
"""Unit tests for the toggle_per_crack_enabled runtime toggle."""
import importlib.util
import json
from pathlib import Path
from hate_crack import notify as _notify
PROJECT_ROOT = Path(__file__).resolve().parents[1]
_CLI_SPEC = importlib.util.spec_from_file_location(
"hate_crack_cli_percrack", PROJECT_ROOT / "hate_crack.py"
)
CLI_MODULE = importlib.util.module_from_spec(_CLI_SPEC)
_CLI_SPEC.loader.exec_module(CLI_MODULE)
def _init_with(tmp_path: Path, **overrides) -> Path:
"""Seed a config file with defaults + overrides and init the notify module."""
config_path = tmp_path / "config.json"
cfg = {
"notify_enabled": False,
"notify_per_crack_enabled": False,
"notify_pushover_token": "",
"notify_pushover_user": "",
}
cfg.update(overrides)
config_path.write_text(json.dumps(cfg))
_notify.init(str(config_path), cfg)
return config_path
class TestTogglePerCrackEnabled:
def test_off_to_on_flips_and_persists(self, tmp_path: Path) -> None:
config_path = _init_with(tmp_path)
try:
new_state = _notify.toggle_per_crack_enabled()
assert new_state is True
assert _notify.get_settings().per_crack_enabled is True
data = json.loads(config_path.read_text())
assert data["notify_per_crack_enabled"] is True
finally:
_notify.clear_state_for_tests()
def test_on_to_off_flips_and_persists(self, tmp_path: Path) -> None:
config_path = _init_with(tmp_path, notify_per_crack_enabled=True)
try:
new_state = _notify.toggle_per_crack_enabled()
assert new_state is False
assert _notify.get_settings().per_crack_enabled is False
data = json.loads(config_path.read_text())
assert data["notify_per_crack_enabled"] is False
finally:
_notify.clear_state_for_tests()
def test_toggle_without_init_uses_defaults(self) -> None:
# Mirrors the behavior of toggle_enabled: must not crash when init
# was never called. The toggle flips an in-memory default; nothing
# is persisted because _config_path is None.
try:
_notify.clear_state_for_tests()
new_state = _notify.toggle_per_crack_enabled()
assert new_state is True
assert _notify.get_settings().per_crack_enabled is True
finally:
_notify.clear_state_for_tests()
def test_does_not_touch_global_enabled(self, tmp_path: Path) -> None:
config_path = _init_with(tmp_path, notify_enabled=False)
try:
_notify.toggle_per_crack_enabled()
data = json.loads(config_path.read_text())
# notify_enabled stays False; only per-crack flipped.
assert data["notify_enabled"] is False
assert data["notify_per_crack_enabled"] is True
finally:
_notify.clear_state_for_tests()
class TestTogglePerCrackNotificationsUI:
def _seed_settings(self, monkeypatch, *, enabled: bool, per_crack: bool):
from hate_crack.notify.settings import NotifySettings
settings = NotifySettings(enabled=enabled, per_crack_enabled=per_crack)
monkeypatch.setattr(CLI_MODULE._notify, "get_settings", lambda: settings)
return settings
def test_guard_refuses_on_when_global_off(self, monkeypatch, capsys):
self._seed_settings(monkeypatch, enabled=False, per_crack=False)
called = {"n": 0}
def _fake_toggle() -> bool:
called["n"] += 1
return True
monkeypatch.setattr(
CLI_MODULE._notify, "toggle_per_crack_enabled", _fake_toggle
)
CLI_MODULE.toggle_per_crack_notifications()
captured = capsys.readouterr().out
assert "Global Pushover notifications are OFF" in captured
assert called["n"] == 0
def test_flips_on_when_global_on(self, monkeypatch, capsys):
self._seed_settings(monkeypatch, enabled=True, per_crack=False)
called = {"n": 0}
def _fake_toggle() -> bool:
called["n"] += 1
return True
monkeypatch.setattr(
CLI_MODULE._notify, "toggle_per_crack_enabled", _fake_toggle
)
CLI_MODULE.toggle_per_crack_notifications()
captured = capsys.readouterr().out
assert "Per-crack notifications are now ON" in captured
assert called["n"] == 1
def test_off_to_off_is_allowed_even_if_global_off(self, monkeypatch, capsys):
# Turning OFF must always succeed, even with global OFF, so a user
# can clean up an inconsistent (per_crack=True, enabled=False) config.
self._seed_settings(monkeypatch, enabled=False, per_crack=True)
called = {"n": 0}
def _fake_toggle() -> bool:
called["n"] += 1
return False
monkeypatch.setattr(
CLI_MODULE._notify, "toggle_per_crack_enabled", _fake_toggle
)
CLI_MODULE.toggle_per_crack_notifications()
captured = capsys.readouterr().out
assert "Per-crack notifications are now OFF" in captured
assert called["n"] == 1

View File

@@ -1,4 +1,5 @@
"""Unit tests for hate_crack.notify.settings."""
import json
from pathlib import Path
@@ -7,6 +8,7 @@ from hate_crack.notify.settings import (
add_to_allowlist,
load_settings,
save_enabled,
save_per_crack_enabled,
)
@@ -39,16 +41,18 @@ class TestLoadSettings:
assert load_settings(None) == NotifySettings()
def test_load_full_dict(self) -> None:
s = load_settings({
"notify_enabled": True,
"notify_pushover_token": "tok",
"notify_pushover_user": "usr",
"notify_per_crack_enabled": True,
"notify_attack_allowlist": ["Brute Force", "Dictionary"],
"notify_suppress_in_orchestrators": False,
"notify_max_cracks_per_burst": 20,
"notify_poll_interval_seconds": 2.5,
})
s = load_settings(
{
"notify_enabled": True,
"notify_pushover_token": "tok",
"notify_pushover_user": "usr",
"notify_per_crack_enabled": True,
"notify_attack_allowlist": ["Brute Force", "Dictionary"],
"notify_suppress_in_orchestrators": False,
"notify_max_cracks_per_burst": 20,
"notify_poll_interval_seconds": 2.5,
}
)
assert s.enabled is True
assert s.pushover_token == "tok"
assert s.pushover_user == "usr"
@@ -59,12 +63,14 @@ class TestLoadSettings:
assert s.poll_interval_seconds == 2.5
def test_load_tolerates_bad_types(self) -> None:
s = load_settings({
"notify_enabled": "true",
"notify_max_cracks_per_burst": "not-a-number",
"notify_poll_interval_seconds": "also-bad",
"notify_attack_allowlist": "not-a-list",
})
s = load_settings(
{
"notify_enabled": "true",
"notify_max_cracks_per_burst": "not-a-number",
"notify_poll_interval_seconds": "also-bad",
"notify_attack_allowlist": "not-a-list",
}
)
# string "true" -> True
assert s.enabled is True
# bad ints fall back to defaults (5, 5.0)
@@ -135,10 +141,14 @@ class TestAddToAllowlist:
def test_preserves_other_entries(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"hcatBin": "hashcat",
"notify_attack_allowlist": ["Existing"],
}))
config_path.write_text(
json.dumps(
{
"hcatBin": "hashcat",
"notify_attack_allowlist": ["Existing"],
}
)
)
add_to_allowlist(str(config_path), "Brute Force")
data = json.loads(config_path.read_text())
assert data["hcatBin"] == "hashcat"
@@ -157,3 +167,32 @@ class TestAddToAllowlist:
add_to_allowlist(str(config_path), "Brute Force")
data = json.loads(config_path.read_text())
assert data["notify_attack_allowlist"] == ["Brute Force"]
class TestSavePerCrackEnabled:
def test_writes_new_config(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
save_per_crack_enabled(str(config_path), True)
data = json.loads(config_path.read_text())
assert data["notify_per_crack_enabled"] is True
def test_preserves_existing_keys(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
initial = {
"hcatBin": "hashcat",
"notify_enabled": True,
"notify_per_crack_enabled": False,
}
config_path.write_text(json.dumps(initial))
save_per_crack_enabled(str(config_path), True)
data = json.loads(config_path.read_text())
assert data["hcatBin"] == "hashcat"
assert data["notify_enabled"] is True
assert data["notify_per_crack_enabled"] is True
def test_toggles_back_and_forth(self, tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
save_per_crack_enabled(str(config_path), True)
save_per_crack_enabled(str(config_path), False)
data = json.loads(config_path.read_text())
assert data["notify_per_crack_enabled"] is False

View File

@@ -32,8 +32,7 @@ MENU_OPTION_TEST_CASES = [
("22", CLI_MODULE._attacks, "combipow_crack", "combipow"),
("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"),
("82", CLI_MODULE, "notifications_submenu", "notifications-submenu"),
("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"),
("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"),
("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"),
@@ -79,3 +78,19 @@ def test_main_menu_option_94_hashview_visible_with_hashview_api_key(monkeypatch)
options = CLI_MODULE.get_main_menu_options()
assert "94" in options
assert options["94"]() == sentinel
def test_main_menu_no_longer_exposes_options_83_84():
"""Options 83 and 84 moved into the Notifications submenu (option 82)."""
options = CLI_MODULE.get_main_menu_options()
assert "83" not in options
assert "84" not in options
assert "82" in options
def test_main_menu_items_include_notifications_entry():
items = dict(CLI_MODULE.get_main_menu_items())
assert "82" in items
assert "Notifications" in items["82"]
assert "83" not in items
assert "84" not in items