mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-04-28 12:03:11 -07:00
test(api): add TestStreamedDownload, TestHashmobBackoff, redirect-bug, and skip-existing tests
Covers the four new private helpers (_stream_response_to_file, _streamed_download, _with_hashmob_backoff, _Hashmob429) and the list_and_download_official_wordlists skip-existing path (10 new tests, 40 total in the file). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from hate_crack.api import (
|
||||
check_7z,
|
||||
@@ -14,7 +14,12 @@ from hate_crack.api import (
|
||||
get_hcat_potfile_path,
|
||||
list_and_download_hashmob_rules,
|
||||
sanitize_filename,
|
||||
_Hashmob429,
|
||||
_streamed_download,
|
||||
_with_hashmob_backoff,
|
||||
list_and_download_official_wordlists,
|
||||
)
|
||||
import requests as req_lib
|
||||
|
||||
|
||||
class TestSanitizeFilename:
|
||||
@@ -300,3 +305,165 @@ class TestParallelRuleDownloads:
|
||||
assert mock_dl.call_args.args[0] == "new.rule"
|
||||
captured = capsys.readouterr()
|
||||
assert "Skipping already downloaded" in captured.out
|
||||
|
||||
|
||||
def _make_mock_response(
|
||||
status_code=200,
|
||||
content=b"file data",
|
||||
content_type="application/octet-stream",
|
||||
headers=None,
|
||||
):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.__enter__ = lambda s: mock_resp
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_resp.status_code = status_code
|
||||
mock_resp.headers = {
|
||||
"Content-Type": content_type,
|
||||
"content-length": str(len(content)),
|
||||
**(headers or {}),
|
||||
}
|
||||
mock_resp.iter_content.return_value = [content]
|
||||
mock_resp.content = content
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
return mock_resp
|
||||
|
||||
|
||||
class TestStreamedDownload:
|
||||
def test_happy_path(self, tmp_path):
|
||||
content = b"hello data"
|
||||
mock_resp = _make_mock_response(status_code=200, content=content)
|
||||
out = tmp_path / "out.txt"
|
||||
with patch("hate_crack.api.requests.get", return_value=mock_resp) as mock_get:
|
||||
result = _streamed_download("https://example.com/file.txt", str(out))
|
||||
assert result is True
|
||||
assert out.exists()
|
||||
assert out.read_bytes() == content
|
||||
assert not (tmp_path / "out.txt.part").exists()
|
||||
mock_get.assert_called_once()
|
||||
assert mock_get.call_args.args[0] == "https://example.com/file.txt"
|
||||
|
||||
def test_partial_cleanup_on_error(self, tmp_path):
|
||||
mock_resp = _make_mock_response(status_code=200, content=b"some data")
|
||||
mock_resp.iter_content.side_effect = req_lib.exceptions.ChunkedEncodingError(
|
||||
"network error"
|
||||
)
|
||||
out = tmp_path / "out.txt"
|
||||
with patch("hate_crack.api.requests.get", return_value=mock_resp):
|
||||
result = _streamed_download("https://example.com/file.txt", str(out))
|
||||
assert result is False
|
||||
assert not out.exists()
|
||||
assert not (tmp_path / "out.txt.part").exists()
|
||||
|
||||
def test_keyboardinterrupt_cleanup(self, tmp_path):
|
||||
mock_resp = _make_mock_response(status_code=200, content=b"some data")
|
||||
mock_resp.iter_content.side_effect = KeyboardInterrupt
|
||||
out = tmp_path / "out.txt"
|
||||
ki_raised = False
|
||||
with patch("hate_crack.api.requests.get", return_value=mock_resp):
|
||||
try:
|
||||
_streamed_download("https://example.com/file.txt", str(out))
|
||||
except KeyboardInterrupt:
|
||||
ki_raised = True
|
||||
assert ki_raised
|
||||
assert not (tmp_path / "out.txt.part").exists()
|
||||
|
||||
def test_skip_existing(self, tmp_path):
|
||||
out = tmp_path / "out.txt"
|
||||
out.write_bytes(b"already here")
|
||||
with patch("hate_crack.api.requests.get") as mock_get:
|
||||
result = _streamed_download(
|
||||
"https://example.com/file.txt", str(out), skip_existing=True
|
||||
)
|
||||
assert result is True
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
class TestHashmobBackoff:
|
||||
def test_gives_up_after_max_attempts(self, capsys):
|
||||
fn = MagicMock(side_effect=_Hashmob429)
|
||||
with patch("time.sleep") as mock_sleep, \
|
||||
patch("hate_crack.api._hashmob_limiter.wait"):
|
||||
result = _with_hashmob_backoff(fn, max_attempts=3, base_delay=1, step=1, max_delay=10)
|
||||
assert result is False
|
||||
assert fn.call_count == 3
|
||||
# sleep called between attempts, but NOT after the last attempt
|
||||
assert mock_sleep.call_count == 2
|
||||
captured = capsys.readouterr()
|
||||
assert "gave up after 3 attempts" in captured.out
|
||||
|
||||
def test_succeeds_on_first_try(self):
|
||||
fn = MagicMock(return_value=True)
|
||||
with patch("time.sleep") as mock_sleep:
|
||||
result = _with_hashmob_backoff(fn)
|
||||
assert result is True
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
def test_succeeds_after_retry(self):
|
||||
fn = MagicMock(side_effect=[_Hashmob429(), _Hashmob429(), True])
|
||||
with patch("time.sleep") as mock_sleep, \
|
||||
patch("hate_crack.api._hashmob_limiter.wait"):
|
||||
result = _with_hashmob_backoff(fn, max_attempts=6, base_delay=1, step=1, max_delay=10)
|
||||
assert result is True
|
||||
assert fn.call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
def test_non_429_exception_reraises(self):
|
||||
fn = MagicMock(side_effect=ValueError("not a 429"))
|
||||
with pytest.raises(ValueError, match="not a 429"):
|
||||
_with_hashmob_backoff(fn)
|
||||
|
||||
|
||||
class TestHashmobWordlistRedirectBugFix:
|
||||
def test_meta_refresh_redirect_uses_verbatim_url(self, tmp_path):
|
||||
real_url = "https://real-server.example.com/actual_file.txt"
|
||||
html_content = (
|
||||
"<html><head>"
|
||||
'<meta http-equiv="refresh" content="0;url=https://real-server.example.com/actual_file.txt">'
|
||||
"</head></html>"
|
||||
).encode()
|
||||
mock_resp = _make_mock_response(
|
||||
status_code=200,
|
||||
content=html_content,
|
||||
content_type="text/plain",
|
||||
)
|
||||
with patch("hate_crack.api.requests.get", return_value=mock_resp), \
|
||||
patch("hate_crack.api.time.sleep"), \
|
||||
patch("hate_crack.api._hashmob_limiter.wait"), \
|
||||
patch("hate_crack.api._streamed_download", return_value=True) as mock_sd:
|
||||
download_hashmob_wordlist("some_file.txt", str(tmp_path / "out.txt"))
|
||||
|
||||
mock_sd.assert_called_once()
|
||||
called_url = mock_sd.call_args.args[0]
|
||||
assert called_url == real_url, (
|
||||
f"Expected verbatim redirect URL '{real_url}', got '{called_url}'"
|
||||
)
|
||||
assert "hashmob.net" not in called_url or called_url == real_url
|
||||
|
||||
|
||||
class TestListAndDownloadOfficialWordlistsSkipExisting:
|
||||
def test_skips_already_downloaded_in_all_branch(self, tmp_path, capsys):
|
||||
wordlists_dir = tmp_path / "wordlists"
|
||||
wordlists_dir.mkdir()
|
||||
# Pre-create existing.txt with content so it passes the size>0 check
|
||||
(wordlists_dir / "existing.txt").write_bytes(b"already downloaded")
|
||||
|
||||
api_data = [{"file_name": "existing.txt"}, {"file_name": "new.txt"}]
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = api_data
|
||||
|
||||
mock_stdin = MagicMock()
|
||||
mock_stdin.isatty.return_value = True
|
||||
|
||||
with patch("hate_crack.api.requests.get", return_value=mock_resp), \
|
||||
patch("hate_crack.api.get_hcat_wordlists_dir", return_value=str(wordlists_dir)), \
|
||||
patch("hate_crack.api.download_official_wordlist") as mock_dl, \
|
||||
patch("hate_crack.api.sys.stdin", mock_stdin), \
|
||||
patch("builtins.input", return_value="a"):
|
||||
list_and_download_official_wordlists()
|
||||
|
||||
assert mock_dl.call_count == 1
|
||||
called_filename = mock_dl.call_args.args[0]
|
||||
assert called_filename == "new.txt"
|
||||
captured = capsys.readouterr()
|
||||
assert "Skipping existing.txt" in captured.out
|
||||
|
||||
Reference in New Issue
Block a user