mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-04-28 12:03:11 -07:00
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>
89 lines
3.7 KiB
Python
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
|