Files
hate_crack/tests/test_notify_pushover.py
Justin Bollinger f9926c0b41 feat(notify): add notification package with Pushover backend
Introduce hate_crack.notify package with a small functional public API
and a CrackTailer thread for polling hashcat output files. Package
layout keeps the HTTP call (_send_pushover) isolated so future backends
(Slack, generic webhooks) can be added as a sibling function rather
than a framework rewrite.

Core pieces:
- settings.py: NotifySettings dataclass plus atomic config persistence
  (save_enabled, add_to_allowlist) via read-modify-write + os.replace.
- pushover.py: single _send_pushover() that never raises; network
  errors, missing requests, and missing creds all funnel to False.
- _suppress.py: thread-local suppression context manager so
  orchestrator attacks can chain primitives without flooding
  notifications.
- tailer.py: CrackTailer(threading.Thread) that seeks to EOF on start,
  polls at a user-configurable interval, and collapses per-tick bursts
  into a single aggregate notification when they exceed the cap.
- __init__.py: public API (init, prompt_notify_for_attack,
  notify_job_done, notify_crack, start_tailer, stop_tailer,
  toggle_enabled, suppressed_notifications). Privacy guarantee:
  notification payloads contain only attack name, counts, and hash
  path, never plaintexts.

72 new tests cover dataclass defaults, atomic config writes, idempotent
allowlist updates, HTTP payload privacy, suppression nesting and
thread-locality, tailer EOF seek, burst cap, truncation recovery, and
the per-attack prompt's [y/n/always] flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:41:38 -04:00

89 lines
3.7 KiB
Python

"""Unit tests for the Pushover HTTP backend."""
from unittest.mock import MagicMock, patch
from hate_crack.notify import pushover
def _mock_response(status_code: int = 200) -> MagicMock:
r = MagicMock()
r.status_code = status_code
return r
class TestSendPushoverSuccess:
def test_returns_true_on_http_200(self) -> None:
with patch.object(pushover, "requests") as mock_requests:
mock_requests.post.return_value = _mock_response(200)
ok = pushover._send_pushover("tok", "usr", "title", "msg")
assert ok is True
def test_payload_has_no_plaintext(self) -> None:
with patch.object(pushover, "requests") as mock_requests:
mock_requests.post.return_value = _mock_response(200)
pushover._send_pushover("tok", "usr", "Crack complete", "alice cracked")
(url,), kwargs = mock_requests.post.call_args
assert url == pushover.PUSHOVER_URL
data = kwargs["data"]
assert set(data.keys()) == {"token", "user", "title", "message"}
assert data["token"] == "tok"
assert data["user"] == "usr"
# The whole payload is just title + message + creds. No 'password',
# 'hash', 'plaintext' or similar keys ever appear.
assert "password" not in data
assert "plaintext" not in data
assert "hash" not in data
def test_timeout_is_passed(self) -> None:
with patch.object(pushover, "requests") as mock_requests:
mock_requests.post.return_value = _mock_response(200)
pushover._send_pushover("tok", "usr", "t", "m")
_, kwargs = mock_requests.post.call_args
assert kwargs["timeout"] == 10
class TestSendPushoverFailureModes:
def test_missing_token_returns_false_without_calling_requests(self) -> None:
with patch.object(pushover, "requests") as mock_requests:
ok = pushover._send_pushover("", "usr", "t", "m")
assert ok is False
mock_requests.post.assert_not_called()
def test_missing_user_returns_false(self) -> None:
with patch.object(pushover, "requests") as mock_requests:
ok = pushover._send_pushover("tok", "", "t", "m")
assert ok is False
mock_requests.post.assert_not_called()
def test_network_error_returns_false(self) -> None:
with patch.object(pushover, "requests") as mock_requests:
mock_requests.post.side_effect = ConnectionError("refused")
ok = pushover._send_pushover("tok", "usr", "t", "m")
assert ok is False
def test_generic_exception_returns_false(self) -> None:
with patch.object(pushover, "requests") as mock_requests:
mock_requests.post.side_effect = RuntimeError("boom")
ok = pushover._send_pushover("tok", "usr", "t", "m")
assert ok is False
def test_non_200_response_returns_false(self) -> None:
with patch.object(pushover, "requests") as mock_requests:
mock_requests.post.return_value = _mock_response(500)
ok = pushover._send_pushover("tok", "usr", "t", "m")
assert ok is False
def test_missing_requests_returns_false(self) -> None:
with patch.object(pushover, "requests", None):
ok = pushover._send_pushover("tok", "usr", "t", "m")
assert ok is False
def test_never_raises(self) -> None:
# Even if requests.post returns something weird (no status_code),
# the function must not raise.
bad = MagicMock()
# Accessing .status_code returns a non-int Mock — it will not equal 200.
with patch.object(pushover, "requests") as mock_requests:
mock_requests.post.return_value = bad
ok = pushover._send_pushover("tok", "usr", "t", "m")
assert ok is False