Files
hate_crack/tests/test_main_utils.py
Justin Bollinger 978a24a7c2 test: add 150 tests for attacks, wrappers, utils, api, and proxy
- tests/test_attacks_behavior.py: 29 tests for attack handler logic
  (loopback, extensive, top_mask, combinator, hybrid, ollama, simple pass-throughs)
- tests/test_hashcat_wrappers.py: 33 tests for hashcat subprocess wrappers
  (brute force, quick dict, combination, hybrid, prince, recycle, good measure, etc.)
- tests/test_main_utils.py: 44 tests for utility functions
  (_append_potfile_arg, generate_session_id, _ensure_hashfile_in_cwd,
  _run_hashcat_show, _dedup_netntlm_by_username, path resolution, cleanup)
- tests/test_api_downloads.py: 25 tests for api.py functions
  (sanitize_filename, check_7z, potfile config, hashmob key, extract_with_7z, download)
- tests/test_proxy.py: 18 tests for root module proxy mechanism
  (__getattr__, _sync_globals_to_main, _sync_callables_to_main, symbol re-export)

Also fix combinator_crack to abort gracefully when hcatCombinationWordlist is
a single string (only 1 wordlist configured) instead of crashing with IndexError.
2026-03-02 17:16:54 -05:00

481 lines
18 KiB
Python

"""Tests for utility functions in hate_crack/main.py."""
import os
from unittest.mock import MagicMock, patch
import pytest
from hate_crack.main import _dedup_netntlm_by_username
@pytest.fixture
def main_module(hc_module):
return hc_module._main
class TestAppendPotfileArg:
def test_appends_potfile_path(self, main_module):
with patch.object(main_module, "hcatPotfilePath", "/some/path"):
cmd = []
main_module._append_potfile_arg(cmd)
assert "--potfile-path=/some/path" in cmd
def test_no_append_empty_potfile(self, main_module):
with patch.object(main_module, "hcatPotfilePath", ""):
cmd = []
main_module._append_potfile_arg(cmd)
assert cmd == []
def test_disabled_by_flag(self, main_module):
with patch.object(main_module, "hcatPotfilePath", "/some/path"):
cmd = []
main_module._append_potfile_arg(cmd, use_potfile_path=False)
assert cmd == []
def test_explicit_potfile_overrides_global(self, main_module):
with patch.object(main_module, "hcatPotfilePath", "/global/path"):
cmd = []
main_module._append_potfile_arg(cmd, potfile_path="/custom/path")
assert "--potfile-path=/custom/path" in cmd
assert "--potfile-path=/global/path" not in cmd
def test_explicit_potfile_when_global_empty(self, main_module):
with patch.object(main_module, "hcatPotfilePath", ""):
cmd = []
main_module._append_potfile_arg(cmd, potfile_path="/explicit/path")
assert "--potfile-path=/explicit/path" in cmd
class TestGenerateSessionId:
def test_basic_filename(self, main_module):
with patch("hate_crack.main.hcatHashFile", "/tmp/myfile.txt", create=True):
result = main_module.generate_session_id()
assert result == "myfile"
def test_with_hyphens_and_underscores(self, main_module):
with patch("hate_crack.main.hcatHashFile", "/path/to/my-file_v2.txt", create=True):
result = main_module.generate_session_id()
assert result == "my-file_v2"
def test_dots_replaced(self, main_module):
with patch("hate_crack.main.hcatHashFile", "/tmp/file.with.dots.txt", create=True):
result = main_module.generate_session_id()
assert result == "file_with_dots"
def test_spaces_replaced(self, main_module):
with patch("hate_crack.main.hcatHashFile", "/tmp/file with spaces.txt", create=True):
result = main_module.generate_session_id()
assert result == "file_with_spaces"
def test_returns_nonempty_string(self, main_module):
with patch("hate_crack.main.hcatHashFile", "/tmp/somefile.txt", create=True):
result = main_module.generate_session_id()
assert isinstance(result, str)
assert len(result) > 0
def test_only_safe_chars(self, main_module):
with patch("hate_crack.main.hcatHashFile", "/tmp/f!le@na#me.txt", create=True):
result = main_module.generate_session_id()
import re
assert re.fullmatch(r"[a-zA-Z0-9_-]+", result) is not None
class TestEnsureHashfileInCwd:
def test_none_returns_none(self, main_module):
result = main_module._ensure_hashfile_in_cwd(None)
assert result is None
def test_empty_string_returns_empty(self, main_module):
result = main_module._ensure_hashfile_in_cwd("")
assert result == ""
def test_relative_path_unchanged(self, main_module):
result = main_module._ensure_hashfile_in_cwd("relative/path.txt")
assert result == "relative/path.txt"
def test_already_in_cwd(self, main_module, tmp_path):
target = tmp_path / "hashfile.txt"
target.write_text("hashes")
with patch("os.getcwd", return_value=str(tmp_path)):
result = main_module._ensure_hashfile_in_cwd(str(target))
assert result == str(target)
def test_different_dir_existing_file_in_cwd(self, main_module, tmp_path):
# File exists in a different directory
other_dir = tmp_path / "other"
other_dir.mkdir()
source_file = other_dir / "hashes.txt"
source_file.write_text("hashes")
# A file with the same name already exists in cwd
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir()
cwd_copy = cwd_dir / "hashes.txt"
cwd_copy.write_text("cwd version")
with patch("os.getcwd", return_value=str(cwd_dir)):
result = main_module._ensure_hashfile_in_cwd(str(source_file))
assert result == str(cwd_copy)
def test_different_dir_creates_symlink(self, main_module, tmp_path):
# Source file in a different directory, nothing in cwd
other_dir = tmp_path / "other"
other_dir.mkdir()
source_file = other_dir / "hashes.txt"
source_file.write_text("hashes")
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir()
with patch("os.getcwd", return_value=str(cwd_dir)):
result = main_module._ensure_hashfile_in_cwd(str(source_file))
expected = str(cwd_dir / "hashes.txt")
assert result == expected
assert os.path.exists(expected)
class TestRunHashcatShow:
def _make_mock_result(self, stdout_bytes):
mock_result = MagicMock()
mock_result.stdout = stdout_bytes
return mock_result
def test_show_flag_present(self, main_module, tmp_path):
mock_result = self._make_mock_result(b"")
output = tmp_path / "out.txt"
captured_cmd = []
def fake_run(cmd, **kwargs):
captured_cmd.extend(cmd)
return mock_result
with (
patch("hate_crack.main.subprocess.run", side_effect=fake_run),
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatPotfilePath", ""),
):
main_module._run_hashcat_show("1000", "/tmp/h.txt", str(output))
assert "--show" in captured_cmd
def test_valid_lines_written(self, main_module, tmp_path):
stdout = b"abc123:password\ndeadbeef:hunter2\n"
mock_result = self._make_mock_result(stdout)
output = tmp_path / "out.txt"
with (
patch("hate_crack.main.subprocess.run", return_value=mock_result),
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatPotfilePath", ""),
):
main_module._run_hashcat_show("1000", "/tmp/h.txt", str(output))
lines = output.read_text().splitlines()
assert "abc123:password" in lines
assert "deadbeef:hunter2" in lines
def test_hash_parsing_error_excluded(self, main_module, tmp_path):
stdout = b"abc123:password\nHash parsing error: bad line\n"
mock_result = self._make_mock_result(stdout)
output = tmp_path / "out.txt"
with (
patch("hate_crack.main.subprocess.run", return_value=mock_result),
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatPotfilePath", ""),
):
main_module._run_hashcat_show("1000", "/tmp/h.txt", str(output))
content = output.read_text()
assert "Hash parsing error" not in content
assert "abc123:password" in content
def test_star_prefix_excluded(self, main_module, tmp_path):
stdout = b"abc123:password\n* Device #1: ...\n"
mock_result = self._make_mock_result(stdout)
output = tmp_path / "out.txt"
with (
patch("hate_crack.main.subprocess.run", return_value=mock_result),
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatPotfilePath", ""),
):
main_module._run_hashcat_show("1000", "/tmp/h.txt", str(output))
content = output.read_text()
assert "* Device" not in content
def test_lines_without_colon_excluded(self, main_module, tmp_path):
stdout = b"abc123:password\nlinewithoutseparator\n"
mock_result = self._make_mock_result(stdout)
output = tmp_path / "out.txt"
with (
patch("hate_crack.main.subprocess.run", return_value=mock_result),
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatPotfilePath", ""),
):
main_module._run_hashcat_show("1000", "/tmp/h.txt", str(output))
content = output.read_text()
assert "linewithoutseparator" not in content
assert "abc123:password" in content
def test_potfile_path_included_when_set(self, main_module, tmp_path):
mock_result = self._make_mock_result(b"")
output = tmp_path / "out.txt"
captured_cmd = []
def fake_run(cmd, **kwargs):
captured_cmd.extend(cmd)
return mock_result
with (
patch("hate_crack.main.subprocess.run", side_effect=fake_run),
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatPotfilePath", "/my/potfile"),
):
main_module._run_hashcat_show("1000", "/tmp/h.txt", str(output))
assert any("--potfile-path=/my/potfile" in arg for arg in captured_cmd)
class TestDedupNetntlmByUsername:
def test_no_duplicates_no_output_file(self, tmp_path):
input_file = tmp_path / "hashes.txt"
input_file.write_text("user1::domain:challenge:response:blob\nuser2::domain:challenge:response:blob\n")
output_file = tmp_path / "deduped.txt"
total, dupes = _dedup_netntlm_by_username(str(input_file), str(output_file))
assert total == 2
assert dupes == 0
assert not output_file.exists()
def test_duplicates_removed(self, tmp_path):
input_file = tmp_path / "hashes.txt"
input_file.write_text(
"alice::domain:aaa:bbb:ccc\n"
"bob::domain:ddd:eee:fff\n"
"alice::domain:111:222:333\n"
)
output_file = tmp_path / "deduped.txt"
total, dupes = _dedup_netntlm_by_username(str(input_file), str(output_file))
assert total == 3
assert dupes == 1
assert output_file.exists()
lines = output_file.read_text().splitlines()
assert len(lines) == 2
assert any("alice" in line for line in lines)
assert any("bob" in line for line in lines)
def test_only_first_occurrence_kept(self, tmp_path):
input_file = tmp_path / "hashes.txt"
input_file.write_text(
"alice::domain:first:aaa:bbb\n"
"alice::domain:second:ccc:ddd\n"
)
output_file = tmp_path / "deduped.txt"
_dedup_netntlm_by_username(str(input_file), str(output_file))
content = output_file.read_text()
assert "first" in content
assert "second" not in content
def test_empty_file(self, tmp_path):
input_file = tmp_path / "empty.txt"
input_file.write_text("")
output_file = tmp_path / "deduped.txt"
total, dupes = _dedup_netntlm_by_username(str(input_file), str(output_file))
assert total == 0
assert dupes == 0
assert not output_file.exists()
def test_missing_input_file(self, tmp_path):
input_file = tmp_path / "nonexistent.txt"
output_file = tmp_path / "deduped.txt"
total, dupes = _dedup_netntlm_by_username(str(input_file), str(output_file))
assert total == 0
assert dupes == 0
def test_lines_without_delimiter(self, tmp_path):
input_file = tmp_path / "hashes.txt"
input_file.write_text("nodeilimiter\nnodeilimiter\n")
output_file = tmp_path / "deduped.txt"
# Should not raise; whole line treated as username
total, dupes = _dedup_netntlm_by_username(str(input_file), str(output_file))
assert total == 2
assert dupes == 1
def test_case_insensitive_username_dedup(self, tmp_path):
input_file = tmp_path / "hashes.txt"
input_file.write_text("Alice::domain:aaa:bbb:ccc\nalice::domain:ddd:eee:fff\n")
output_file = tmp_path / "deduped.txt"
total, dupes = _dedup_netntlm_by_username(str(input_file), str(output_file))
assert total == 2
assert dupes == 1
class TestResolveWordlistPath:
def test_absolute_existing_file(self, main_module, tmp_path):
wordlist = tmp_path / "words.txt"
wordlist.write_text("word1\nword2\n")
result = main_module._resolve_wordlist_path(str(wordlist), str(tmp_path))
assert result == str(wordlist)
def test_relative_found_in_base_dir(self, main_module, tmp_path):
wordlist = tmp_path / "words.txt"
wordlist.write_text("word1\nword2\n")
result = main_module._resolve_wordlist_path("words.txt", str(tmp_path))
assert result == str(wordlist)
def test_not_found_returns_path_anyway(self, main_module, tmp_path):
# When file not found, returns abspath of first candidate - does not raise
result = main_module._resolve_wordlist_path("missing.txt", str(tmp_path))
assert isinstance(result, str)
assert len(result) > 0
def test_empty_string_returns_empty(self, main_module):
result = main_module._resolve_wordlist_path("", "/some/dir")
assert result == ""
def test_none_returns_none(self, main_module):
result = main_module._resolve_wordlist_path(None, "/some/dir")
assert result is None
class TestGetRulePath:
def test_found_in_rules_directory(self, main_module, tmp_path):
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
rule_file = rules_dir / "best64.rule"
rule_file.write_text("rule content")
with patch.object(main_module, "rulesDirectory", str(rules_dir)):
result = main_module.get_rule_path("best64.rule")
assert result == str(rule_file)
def test_found_in_fallback_dir(self, main_module, tmp_path):
# rulesDirectory has no such file, fallback does
empty_rules_dir = tmp_path / "empty_rules"
empty_rules_dir.mkdir()
fallback_dir = tmp_path / "fallback"
fallback_dir.mkdir()
rule_file = fallback_dir / "custom.rule"
rule_file.write_text("rule content")
with patch.object(main_module, "rulesDirectory", str(empty_rules_dir)):
result = main_module.get_rule_path("custom.rule", fallback_dir=str(fallback_dir))
assert result == str(rule_file)
def test_not_found_returns_first_candidate(self, main_module, tmp_path):
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
with patch.object(main_module, "rulesDirectory", str(rules_dir)):
result = main_module.get_rule_path("nonexistent.rule")
assert result == str(rules_dir / "nonexistent.rule")
def test_no_rules_directory_no_fallback_returns_rule_name(self, main_module):
with patch.object(main_module, "rulesDirectory", ""):
result = main_module.get_rule_path("some.rule")
assert result == "some.rule"
def test_fallback_checked_after_rules_directory(self, main_module, tmp_path):
# Both directories have the rule; rules_directory takes priority
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
fallback_dir = tmp_path / "fallback"
fallback_dir.mkdir()
(rules_dir / "priority.rule").write_text("rules version")
(fallback_dir / "priority.rule").write_text("fallback version")
with patch.object(main_module, "rulesDirectory", str(rules_dir)):
result = main_module.get_rule_path("priority.rule", fallback_dir=str(fallback_dir))
assert result == str(rules_dir / "priority.rule")
class TestCleanupWordlistArtifacts:
def test_removes_out_files_from_cwd(self, main_module, tmp_path):
artifact = tmp_path / "cracked.out"
artifact.write_text("cracked passwords")
with (
patch("os.getcwd", return_value=str(tmp_path)),
patch.object(main_module, "hate_path", str(tmp_path)),
patch.object(main_module, "hcatWordlists", str(tmp_path / "wordlists")),
):
main_module.cleanup_wordlist_artifacts()
assert not artifact.exists()
def test_preserves_non_artifact_files(self, main_module, tmp_path):
keeper = tmp_path / "important.txt"
keeper.write_text("keep me")
artifact = tmp_path / "remove.out"
artifact.write_text("remove me")
with (
patch("os.getcwd", return_value=str(tmp_path)),
patch.object(main_module, "hate_path", str(tmp_path)),
patch.object(main_module, "hcatWordlists", str(tmp_path / "wordlists")),
):
main_module.cleanup_wordlist_artifacts()
assert keeper.exists()
assert not artifact.exists()
def test_removes_out_files_from_hate_path(self, main_module, tmp_path):
hate_dir = tmp_path / "hate_crack"
hate_dir.mkdir()
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir()
artifact = hate_dir / "session.out"
artifact.write_text("output")
with (
patch("os.getcwd", return_value=str(cwd_dir)),
patch.object(main_module, "hate_path", str(hate_dir)),
patch.object(main_module, "hcatWordlists", str(tmp_path / "wordlists")),
):
main_module.cleanup_wordlist_artifacts()
assert not artifact.exists()
def test_missing_directory_does_not_raise(self, main_module, tmp_path):
nonexistent = tmp_path / "nonexistent"
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir()
with (
patch("os.getcwd", return_value=str(cwd_dir)),
patch.object(main_module, "hate_path", str(nonexistent)),
patch.object(main_module, "hcatWordlists", str(tmp_path / "wordlists")),
):
# Should not raise even when directories don't exist
main_module.cleanup_wordlist_artifacts()