diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 31eb84f..f93586b 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -389,6 +389,12 @@ def combinator_crack(ctx: Any) -> None: ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists[:2] ] + if len(wordlists) < 2: + print("\n[!] Combinator attack requires 2 wordlists but only 1 is configured.") + print("Set hcatCombinationWordlist to a list of 2 paths in config.json.") + print("Aborting combinator attack.") + return + print("\nStarting combinator attack with 2 wordlists:") print(f" Wordlist 1: {wordlists[0]}") print(f" Wordlist 2: {wordlists[1]}") diff --git a/tests/test_api_downloads.py b/tests/test_api_downloads.py new file mode 100644 index 0000000..8288709 --- /dev/null +++ b/tests/test_api_downloads.py @@ -0,0 +1,227 @@ +import json +import os + +from unittest.mock import MagicMock, patch + +from hate_crack.api import ( + check_7z, + check_transmission_cli, + download_hashmob_wordlist, + extract_with_7z, + get_hashmob_api_key, + get_hcat_potfile_args, + get_hcat_potfile_path, + sanitize_filename, +) + + +class TestSanitizeFilename: + def test_normal_filename_unchanged(self): + assert sanitize_filename("rockyou.txt") == "rockyou.txt" + + def test_spaces_become_underscores(self): + assert sanitize_filename("my file.txt") == "my_file.txt" + + def test_path_separators_removed(self): + # Dots are kept; slashes are removed. "../../etc/passwd" has 4 dots, 2 slashes. + assert sanitize_filename("../../etc/passwd") == "....etcpasswd" + + def test_empty_string(self): + assert sanitize_filename("") == "" + + def test_mixed_case_preserved(self): + assert sanitize_filename("RockYou.txt") == "RockYou.txt" + + +class TestCheck7z: + def test_returns_true_when_found(self, capsys): + with patch("shutil.which", return_value="/usr/bin/7z"): + result = check_7z() + assert result is True + + def test_returns_false_when_missing(self, capsys): + with patch("shutil.which", return_value=None): + result = check_7z() + assert result is False + captured = capsys.readouterr() + assert "7z" in captured.out + + +class TestCheckTransmissionCli: + def test_returns_true_when_found(self): + with patch("shutil.which", return_value="/usr/bin/transmission-cli"): + result = check_transmission_cli() + assert result is True + + def test_returns_false_when_missing(self, capsys): + with patch("shutil.which", return_value=None): + result = check_transmission_cli() + assert result is False + captured = capsys.readouterr() + assert "transmission-cli" in captured.out + + +class TestGetHcatPotfilePath: + def test_returns_config_value_when_set(self, tmp_path): + config_data = {"hcatPotfilePath": "/custom/hashcat.potfile"} + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config_data)) + with patch("hate_crack.api._resolve_config_path", return_value=str(config_file)): + result = get_hcat_potfile_path() + assert result == "/custom/hashcat.potfile" + + def test_returns_default_when_key_missing(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({})) + with patch("hate_crack.api._resolve_config_path", return_value=str(config_file)): + result = get_hcat_potfile_path() + assert result == os.path.expanduser("~/.hashcat/hashcat.potfile") + + def test_returns_default_when_no_config(self): + with patch("hate_crack.api._resolve_config_path", return_value=None): + result = get_hcat_potfile_path() + assert result == os.path.expanduser("~/.hashcat/hashcat.potfile") + + def test_expands_tilde_in_config_value(self, tmp_path): + config_data = {"hcatPotfilePath": "~/.custom/hashcat.potfile"} + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config_data)) + with patch("hate_crack.api._resolve_config_path", return_value=str(config_file)): + result = get_hcat_potfile_path() + assert result == os.path.expanduser("~/.custom/hashcat.potfile") + assert "~" not in result + + +class TestGetHcatPotfileArgs: + def test_returns_list_with_potfile_arg(self): + with patch("hate_crack.api.get_hcat_potfile_path", return_value="/some/path/hashcat.potfile"): + result = get_hcat_potfile_args() + assert result == ["--potfile-path=/some/path/hashcat.potfile"] + + def test_returns_non_empty_list_by_default(self): + # Default path always resolves to something (expanduser never returns empty) + with patch("hate_crack.api._resolve_config_path", return_value=None): + result = get_hcat_potfile_args() + assert len(result) == 1 + assert result[0].startswith("--potfile-path=") + + +class TestGetHashmobApiKey: + def test_returns_key_from_config(self, tmp_path): + config_data = {"hashmob_api_key": "abc123secret"} + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config_data)) + config_path = str(config_file) + # Patch isfile so the function sees our config file as the pkg_dir config, + # and patch open so reads come from it. + with patch("hate_crack.api.os.path.isfile", side_effect=lambda p: p == config_path), \ + patch("hate_crack.api.os.path.dirname", return_value=str(tmp_path)), \ + patch("hate_crack.api.os.path.abspath", side_effect=lambda p: p): + result = get_hashmob_api_key() + assert result == "abc123secret" + + def test_returns_none_when_missing(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({})) + config_path = str(config_file) + with patch("hate_crack.api.os.path.isfile", side_effect=lambda p: p == config_path), \ + patch("hate_crack.api.os.path.dirname", return_value=str(tmp_path)), \ + patch("hate_crack.api.os.path.abspath", side_effect=lambda p: p): + result = get_hashmob_api_key() + assert result is None + + def test_returns_none_when_no_config(self): + with patch("hate_crack.api.os.path.isfile", return_value=False): + result = get_hashmob_api_key() + assert result is None + + +class TestExtractWith7z: + def _make_run_result(self, returncode=0): + result = MagicMock() + result.returncode = returncode + result.stdout = "" + result.stderr = "" + return result + + def test_returns_false_when_not_installed(self, tmp_path, capsys): + with patch("hate_crack.api.shutil.which", return_value=None): + archive = tmp_path / "test.7z" + archive.write_text("fake archive data") + result = extract_with_7z(str(archive), str(tmp_path)) + assert result is False + captured = capsys.readouterr() + assert "7z" in captured.out + + def test_returns_true_on_success(self, tmp_path): + archive = tmp_path / "test.7z" + archive.write_text("fake archive data") + mock_result = self._make_run_result(returncode=0) + with patch("hate_crack.api.shutil.which", return_value="/usr/bin/7z"), \ + patch("subprocess.run", return_value=mock_result): + result = extract_with_7z(str(archive), str(tmp_path), remove_archive=False) + assert result is True + + def test_returns_false_on_failure(self, tmp_path): + archive = tmp_path / "test.7z" + archive.write_text("fake archive data") + mock_result = self._make_run_result(returncode=1) + with patch("hate_crack.api.shutil.which", return_value="/usr/bin/7z"), \ + patch("subprocess.run", return_value=mock_result): + result = extract_with_7z(str(archive), str(tmp_path)) + assert result is False + + def test_removes_archive_on_success(self, tmp_path): + archive = tmp_path / "test.7z" + archive.write_text("fake archive data") + mock_result = self._make_run_result(returncode=0) + with patch("hate_crack.api.shutil.which", return_value="/usr/bin/7z"), \ + patch("subprocess.run", return_value=mock_result): + result = extract_with_7z(str(archive), str(tmp_path), remove_archive=True) + assert result is True + assert not archive.exists() + + def test_keeps_archive_when_remove_false(self, tmp_path): + archive = tmp_path / "test.7z" + archive.write_text("fake archive data") + mock_result = self._make_run_result(returncode=0) + with patch("hate_crack.api.shutil.which", return_value="/usr/bin/7z"), \ + patch("subprocess.run", return_value=mock_result): + result = extract_with_7z(str(archive), str(tmp_path), remove_archive=False) + assert result is True + assert archive.exists() + + +class TestDownloadHashmobWordlist: + def _make_mock_response(self, status_code=200, content=b"wordlist data"): + mock_response = MagicMock() + mock_response.__enter__ = lambda s: mock_response + mock_response.__exit__ = MagicMock(return_value=False) + mock_response.status_code = status_code + mock_response.headers = {"Content-Type": "application/octet-stream"} + mock_response.iter_content.return_value = [content] + mock_response.raise_for_status = MagicMock() + return mock_response + + def test_successful_download(self, tmp_path): + mock_response = self._make_mock_response(status_code=200, content=b"wordlist data") + out = tmp_path / "test.txt" + with patch("hate_crack.api.requests.get", return_value=mock_response), \ + patch("hate_crack.api.time.sleep"): + result = download_hashmob_wordlist("test.txt", str(out)) + assert result is True + assert out.exists() + assert out.read_bytes() == b"wordlist data" + + def test_404_returns_false(self, tmp_path): + import requests as req + + mock_response = self._make_mock_response(status_code=404) + mock_response.raise_for_status.side_effect = req.exceptions.HTTPError( + response=MagicMock(status_code=404) + ) + out = tmp_path / "test.txt" + with patch("hate_crack.api.requests.get", return_value=mock_response), \ + patch("hate_crack.api.time.sleep"): + result = download_hashmob_wordlist("test.txt", str(out)) + assert result is False diff --git a/tests/test_attacks_behavior.py b/tests/test_attacks_behavior.py new file mode 100644 index 0000000..f574997 --- /dev/null +++ b/tests/test_attacks_behavior.py @@ -0,0 +1,368 @@ +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +from hate_crack.attacks import ( + bandrel_method, + combinator_crack, + extensive_crack, + hybrid_crack, + loopback_attack, + middle_combinator, + ollama_attack, + pathwell_crack, + prince_attack, + thorough_combinator, + top_mask_crack, + yolo_combination, +) + + +def _make_ctx(hash_type: str = "1000", hash_file: str = "/tmp/hashes.txt") -> MagicMock: + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + return ctx + + +class TestLoopbackAttack: + def test_no_rules_proceeds_without_rules(self, tmp_path: Path) -> None: + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path / "wordlists") + ctx.rulesDirectory = str(tmp_path / "rules") + os.makedirs(ctx.rulesDirectory, exist_ok=True) + + # No rule files in directory -> prompts for download -> user says "n" + # Then rule_choice becomes ["0"] via the "no rules" branch + with ( + patch("hate_crack.attacks.download_hashmob_rules"), + patch("builtins.input", side_effect=["n", "0"]), + ): + loopback_attack(ctx) + + ctx.hcatQuickDictionary.assert_called_once() + call_kwargs = ctx.hcatQuickDictionary.call_args + assert call_kwargs.kwargs.get("loopback") is True + + def test_with_rule_file_calls_with_rule(self, tmp_path: Path) -> None: + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path / "wordlists") + ctx.rulesDirectory = str(tmp_path / "rules") + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + (rules_dir / "best66.rule").write_text("") + + with patch("builtins.input", return_value="1"): + loopback_attack(ctx) + + ctx.hcatQuickDictionary.assert_called_once() + call_args = ctx.hcatQuickDictionary.call_args + assert call_args.kwargs.get("loopback") is True + # Third positional arg is the rule chain string + assert "best66.rule" in call_args[0][2] + + def test_rule_99_returns_without_calling(self, tmp_path: Path) -> None: + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path / "wordlists") + ctx.rulesDirectory = str(tmp_path / "rules") + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + (rules_dir / "best66.rule").write_text("") + + with patch("builtins.input", return_value="99"): + loopback_attack(ctx) + + ctx.hcatQuickDictionary.assert_not_called() + + def test_creates_empty_wordlist_if_missing(self, tmp_path: Path) -> None: + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path / "wordlists") + ctx.rulesDirectory = str(tmp_path / "rules") + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + (rules_dir / "best66.rule").write_text("") + + empty_txt = tmp_path / "wordlists" / "empty.txt" + assert not empty_txt.exists() + + with patch("builtins.input", return_value="1"): + loopback_attack(ctx) + + assert empty_txt.exists() + + def test_empty_wordlist_passed_to_hcatQuickDictionary( + self, tmp_path: Path + ) -> None: + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path / "wordlists") + ctx.rulesDirectory = str(tmp_path / "rules") + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + (rules_dir / "best66.rule").write_text("") + + with patch("builtins.input", return_value="1"): + loopback_attack(ctx) + + call_args = ctx.hcatQuickDictionary.call_args + # Fourth positional arg is the empty wordlist path + empty_wordlist_arg = call_args[0][3] + assert empty_wordlist_arg.endswith("empty.txt") + + +class TestExtensiveCrack: + def test_calls_all_attack_methods(self) -> None: + ctx = _make_ctx() + + extensive_crack(ctx) + + ctx.hcatBruteForce.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile, "1", "7") + ctx.hcatDictionary.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile) + ctx.hcatTopMask.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile, 4 * 60 * 60) + ctx.hcatFingerprint.assert_called_once_with( + ctx.hcatHashType, ctx.hcatHashFile, 7, run_hybrid_on_expanded=False + ) + ctx.hcatCombination.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile) + ctx.hcatHybrid.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile) + ctx.hcatGoodMeasure.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile) + + def test_calls_recycle_after_each_attack(self) -> None: + ctx = _make_ctx() + + extensive_crack(ctx) + + # extensive_crack calls hcatRecycle after: brute, dictionary, mask, + # fingerprint, combination, hybrid, and once more at the end (hcatExtraCount) + assert ctx.hcatRecycle.call_count == 7 + ctx.hcatRecycle.assert_any_call(ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatBruteCount) + ctx.hcatRecycle.assert_any_call( + ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatDictionaryCount + ) + ctx.hcatRecycle.assert_any_call(ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatMaskCount) + ctx.hcatRecycle.assert_any_call( + ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatFingerprintCount + ) + ctx.hcatRecycle.assert_any_call( + ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatCombinationCount + ) + ctx.hcatRecycle.assert_any_call(ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatHybridCount) + ctx.hcatRecycle.assert_any_call(ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatExtraCount) + + +class TestTopMaskCrack: + def test_default_time_uses_four_hours(self) -> None: + ctx = _make_ctx() + + with patch("builtins.input", return_value=""): + top_mask_crack(ctx) + + ctx.hcatTopMask.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile, 4 * 60 * 60) + + def test_custom_time_converts_hours_to_seconds(self) -> None: + ctx = _make_ctx() + + with patch("builtins.input", return_value="2"): + top_mask_crack(ctx) + + ctx.hcatTopMask.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile, 2 * 60 * 60) + + def test_one_hour_input(self) -> None: + ctx = _make_ctx() + + with patch("builtins.input", return_value="1"): + top_mask_crack(ctx) + + ctx.hcatTopMask.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile, 1 * 60 * 60) + + +class TestCombinatorCrack: + def test_default_list_wordlist_calls_hcatCombination(self) -> None: + ctx = _make_ctx() + ctx.hcatCombinationWordlist = ["/wl/rockyou.txt", "/wl/passwords.txt"] + ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl + + with patch("builtins.input", return_value=""): + combinator_crack(ctx) + + ctx.hcatCombination.assert_called_once_with( + ctx.hcatHashType, + ctx.hcatHashFile, + ["/wl/rockyou.txt", "/wl/passwords.txt"], + ) + + def test_default_single_string_wordlist_aborts_gracefully(self, capsys) -> None: + # When hcatCombinationWordlist is a plain string (one wordlist), the code + # wraps it in a list giving only 1 item - the handler should abort with a + # clear message instead of crashing with IndexError. + ctx = _make_ctx() + ctx.hcatCombinationWordlist = "/wl/rockyou.txt" + ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl + + with patch("builtins.input", return_value="y"): + combinator_crack(ctx) + + ctx.hcatCombination.assert_not_called() + captured = capsys.readouterr() + assert "Aborting combinator attack" in captured.out + + def test_resolve_wordlist_path_called_for_each_wordlist(self) -> None: + ctx = _make_ctx() + ctx.hcatCombinationWordlist = ["/wl/a.txt", "/wl/b.txt"] + ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl + + with patch("builtins.input", return_value=""): + combinator_crack(ctx) + + assert ctx._resolve_wordlist_path.call_count == 2 + ctx._resolve_wordlist_path.assert_any_call("/wl/a.txt", ctx.hcatWordlists) + ctx._resolve_wordlist_path.assert_any_call("/wl/b.txt", ctx.hcatWordlists) + + def test_uses_only_first_two_wordlists(self) -> None: + ctx = _make_ctx() + ctx.hcatCombinationWordlist = ["/wl/a.txt", "/wl/b.txt", "/wl/c.txt"] + ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl + + with patch("builtins.input", return_value=""): + combinator_crack(ctx) + + call_wordlists = ctx.hcatCombination.call_args[0][2] + assert len(call_wordlists) == 2 + assert "/wl/c.txt" not in call_wordlists + + +class TestHybridCrack: + def test_default_list_wordlist_calls_hcatHybrid(self) -> None: + ctx = _make_ctx() + ctx.hcatHybridlist = ["/wl/rockyou.txt"] + ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl + + with patch("builtins.input", return_value=""): + hybrid_crack(ctx) + + ctx.hcatHybrid.assert_called_once_with( + ctx.hcatHashType, + ctx.hcatHashFile, + ["/wl/rockyou.txt"], + ) + + def test_default_string_wordlist_wraps_in_list(self) -> None: + ctx = _make_ctx() + ctx.hcatHybridlist = "/wl/rockyou.txt" + ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl + + with patch("builtins.input", return_value=""): + hybrid_crack(ctx) + + ctx.hcatHybrid.assert_called_once() + call_wordlists = ctx.hcatHybrid.call_args[0][2] + assert "/wl/rockyou.txt" in call_wordlists + + def test_decline_default_aborts_when_no_selection(self) -> None: + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = None + + with patch("builtins.input", return_value="n"): + hybrid_crack(ctx) + + ctx.hcatHybrid.assert_not_called() + + +class TestSimpleAttacks: + def test_pathwell_crack(self) -> None: + ctx = _make_ctx() + + pathwell_crack(ctx) + + ctx.hcatPathwellBruteForce.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile) + + def test_prince_attack(self) -> None: + ctx = _make_ctx() + + prince_attack(ctx) + + ctx.hcatPrince.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile) + + def test_yolo_combination(self) -> None: + ctx = _make_ctx() + + yolo_combination(ctx) + + ctx.hcatYoloCombination.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile) + + def test_thorough_combinator(self) -> None: + ctx = _make_ctx() + + thorough_combinator(ctx) + + ctx.hcatThoroughCombinator.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile) + + def test_middle_combinator(self) -> None: + ctx = _make_ctx() + + middle_combinator(ctx) + + ctx.hcatMiddleCombinator.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile) + + def test_bandrel_method(self) -> None: + ctx = _make_ctx() + + bandrel_method(ctx) + + ctx.hcatBandrel.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile) + + def test_pathwell_crack_passes_hash_type_and_file(self) -> None: + ctx = _make_ctx(hash_type="500", hash_file="/data/hashes.hash") + + pathwell_crack(ctx) + + ctx.hcatPathwellBruteForce.assert_called_once_with("500", "/data/hashes.hash") + + def test_prince_attack_passes_hash_type_and_file(self) -> None: + ctx = _make_ctx(hash_type="500", hash_file="/data/hashes.hash") + + prince_attack(ctx) + + ctx.hcatPrince.assert_called_once_with("500", "/data/hashes.hash") + + +class TestOllamaAttack: + def test_calls_hcatOllama_with_context(self) -> None: + ctx = _make_ctx() + + with patch("builtins.input", side_effect=["ACME", "tech", "NYC"]): + ollama_attack(ctx) + + ctx.hcatOllama.assert_called_once_with( + ctx.hcatHashType, + ctx.hcatHashFile, + "target", + {"company": "ACME", "industry": "tech", "location": "NYC"}, + ) + + def test_passes_hash_type_and_file(self) -> None: + ctx = _make_ctx(hash_type="1800", hash_file="/tmp/sha512.txt") + + with patch("builtins.input", side_effect=["Corp", "finance", "London"]): + ollama_attack(ctx) + + call_args = ctx.hcatOllama.call_args[0] + assert call_args[0] == "1800" + assert call_args[1] == "/tmp/sha512.txt" + + def test_strips_whitespace_from_inputs(self) -> None: + ctx = _make_ctx() + + with patch("builtins.input", side_effect=[" ACME ", " tech ", " NYC "]): + ollama_attack(ctx) + + target_info = ctx.hcatOllama.call_args[0][3] + assert target_info["company"] == "ACME" + assert target_info["industry"] == "tech" + assert target_info["location"] == "NYC" + + def test_target_string_is_literal_target(self) -> None: + ctx = _make_ctx() + + with patch("builtins.input", side_effect=["X", "Y", "Z"]): + ollama_attack(ctx) + + assert ctx.hcatOllama.call_args[0][2] == "target" diff --git a/tests/test_hashcat_wrappers.py b/tests/test_hashcat_wrappers.py new file mode 100644 index 0000000..22e5e19 --- /dev/null +++ b/tests/test_hashcat_wrappers.py @@ -0,0 +1,624 @@ +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def main_module(hc_module): + """Return the underlying hate_crack.main module for direct patching.""" + return hc_module._main + + +def _make_mock_proc(wait_side_effect=None): + proc = MagicMock() + if wait_side_effect is not None: + proc.wait.side_effect = wait_side_effect + else: + proc.wait.return_value = None + proc.pid = 12345 + return proc + + +def _common_patches(main_module, tmp_path): + """Return a list of patch context managers for the most common globals.""" + hash_file = str(tmp_path / "hashes.txt") + return [ + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatHashFile", hash_file, create=True), + patch.object(main_module, "generate_session_id", return_value="test_session"), + ] + + +class TestHcatBruteForce: + def test_contains_attack_mode_3(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \ + patch.object(main_module, "lineCount", return_value=0): + main_module.hcatBruteForce("1000", hash_file, 1, 7) + + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + assert "-a" in cmd + assert "3" in cmd + + def test_increment_flags(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \ + patch.object(main_module, "lineCount", return_value=0): + main_module.hcatBruteForce("1000", hash_file, 3, 9) + cmd = mock_popen.call_args[0][0] + + assert "--increment" in cmd + assert "--increment-min=3" in cmd + assert "--increment-max=9" in cmd + + def test_keyboard_interrupt_kills_process(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc(wait_side_effect=KeyboardInterrupt()) + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc), \ + patch.object(main_module, "lineCount", return_value=0): + main_module.hcatBruteForce("1000", hash_file, 1, 7) + + mock_proc.kill.assert_called_once() + + def test_hash_type_and_file_in_cmd(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \ + patch.object(main_module, "lineCount", return_value=0): + main_module.hcatBruteForce("500", hash_file, 1, 8) + + cmd = mock_popen.call_args[0][0] + assert "-m" in cmd + assert "500" in cmd + assert hash_file in cmd + + +class TestHcatQuickDictionary: + def test_wordlist_added_to_cmd(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wordlist = str(tmp_path / "words.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \ + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \ + patch("hate_crack.main._debug_cmd"): + main_module.hcatQuickDictionary("1000", hash_file, "", wordlist) + + cmd = mock_popen.call_args[0][0] + assert wordlist in cmd + + def test_loopback_flag_added(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wordlist = str(tmp_path / "words.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \ + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \ + patch("hate_crack.main._debug_cmd"): + main_module.hcatQuickDictionary("1000", hash_file, "", wordlist, loopback=True) + + cmd = mock_popen.call_args[0][0] + assert "--loopback" in cmd + + def test_no_loopback_by_default(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wordlist = str(tmp_path / "words.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \ + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \ + patch("hate_crack.main._debug_cmd"): + main_module.hcatQuickDictionary("1000", hash_file, "", wordlist) + + cmd = mock_popen.call_args[0][0] + assert "--loopback" not in cmd + + def test_chains_added_when_provided(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wordlist = str(tmp_path / "words.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \ + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \ + patch("hate_crack.main._debug_cmd"): + main_module.hcatQuickDictionary( + "1000", hash_file, "-r /fake/rule.rule", wordlist + ) + + cmd = mock_popen.call_args[0][0] + assert "-r" in cmd + assert "/fake/rule.rule" in cmd + + def test_list_of_wordlists_all_added(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wl1 = str(tmp_path / "words1.txt") + wl2 = str(tmp_path / "words2.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \ + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \ + patch("hate_crack.main._debug_cmd"): + main_module.hcatQuickDictionary("1000", hash_file, "", [wl1, wl2]) + + cmd = mock_popen.call_args[0][0] + assert wl1 in cmd + assert wl2 in cmd + + +class TestHcatCombination: + def test_contains_attack_mode_1(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wl1 = tmp_path / "words1.txt" + wl2 = tmp_path / "words2.txt" + wl1.write_text("word1\n") + wl2.write_text("word2\n") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatWordlists", str(tmp_path)), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0, create=True): + main_module.hcatCombination( + "1000", hash_file, wordlists=[str(wl1), str(wl2)] + ) + + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + assert "-a" in cmd + assert "1" in cmd + + def test_aborts_with_fewer_than_two_wordlists(self, main_module, tmp_path, capsys): + hash_file = str(tmp_path / "hashes.txt") + wl1 = tmp_path / "words1.txt" + wl1.write_text("word1\n") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatWordlists", str(tmp_path)), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + main_module.hcatCombination("1000", hash_file, wordlists=[str(wl1)]) + + mock_popen.assert_not_called() + captured = capsys.readouterr() + assert "requires at least 2" in captured.out + + +class TestHcatHybrid: + def test_contains_attack_mode_6_or_7(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wl = tmp_path / "words.txt" + wl.write_text("word\n") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatWordlists", str(tmp_path)), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0, create=True): + main_module.hcatHybrid("1000", hash_file, wordlists=[str(wl)]) + + assert mock_popen.call_count >= 1 + all_cmds = [c[0][0] for c in mock_popen.call_args_list] + modes_used = set() + for cmd in all_cmds: + if "-a" in cmd: + idx = cmd.index("-a") + modes_used.add(cmd[idx + 1]) + assert modes_used & {"6", "7"}, f"Expected mode 6 or 7 in cmds, got modes: {modes_used}" + + def test_aborts_when_no_valid_wordlists(self, main_module, tmp_path, capsys): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatWordlists", str(tmp_path)), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + main_module.hcatHybrid( + "1000", hash_file, wordlists=["/nonexistent/wordlist.txt"] + ) + + mock_popen.assert_not_called() + captured = capsys.readouterr() + assert "No valid wordlists" in captured.out + + +class TestHcatPathwellBruteForce: + def test_popen_called(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + main_module.hcatPathwellBruteForce("1000", hash_file) + + mock_popen.assert_called_once() + + def test_attack_mode_3_in_cmd(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + main_module.hcatPathwellBruteForce("1000", hash_file) + + cmd = mock_popen.call_args[0][0] + assert "-a" in cmd + assert "3" in cmd + + def test_pathwell_mask_in_cmd(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + main_module.hcatPathwellBruteForce("1000", hash_file) + + cmd = mock_popen.call_args[0][0] + assert any("pathwell.hcmask" in arg for arg in cmd) + + +class TestHcatPrince: + def test_two_popen_calls_for_pipe(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + prince_base = tmp_path / "prince_base.txt" + prince_base.write_text("password\n") + prince_dir = tmp_path / "princeprocessor" + prince_dir.mkdir() + (prince_dir / "pp64.bin").touch() + + mock_prince_proc = MagicMock() + mock_prince_proc.stdout = MagicMock() + mock_prince_proc.wait.return_value = None + mock_hashcat_proc = MagicMock() + mock_hashcat_proc.wait.return_value = None + mock_hashcat_proc.pid = 12345 + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "hcatPrinceBin", "pp64.bin"), \ + patch.object(main_module, "hcatPrinceBaseList", [str(prince_base)]), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "get_rule_path", return_value="/fake/prince_optimized.rule"), \ + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \ + patch("hate_crack.main.subprocess.Popen") as mock_popen: + mock_popen.side_effect = [mock_prince_proc, mock_hashcat_proc] + main_module.hcatPrince("1000", hash_file) + + assert mock_popen.call_count == 2 + + def test_prince_binary_first_call(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + prince_base = tmp_path / "prince_base.txt" + prince_base.write_text("password\n") + prince_dir = tmp_path / "princeprocessor" + prince_dir.mkdir() + pp_bin = prince_dir / "pp64.bin" + pp_bin.touch() + + mock_prince_proc = MagicMock() + mock_prince_proc.stdout = MagicMock() + mock_prince_proc.wait.return_value = None + mock_hashcat_proc = MagicMock() + mock_hashcat_proc.wait.return_value = None + mock_hashcat_proc.pid = 12345 + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "hcatPrinceBin", "pp64.bin"), \ + patch.object(main_module, "hcatPrinceBaseList", [str(prince_base)]), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "get_rule_path", return_value="/fake/prince_optimized.rule"), \ + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \ + patch("hate_crack.main.subprocess.Popen") as mock_popen: + mock_popen.side_effect = [mock_prince_proc, mock_hashcat_proc] + main_module.hcatPrince("1000", hash_file) + + prince_cmd = mock_popen.call_args_list[0][0][0] + assert str(pp_bin) in prince_cmd + + def test_aborts_when_prince_base_missing(self, main_module, tmp_path, capsys): + hash_file = str(tmp_path / "hashes.txt") + + with patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "hcatPrinceBin", "pp64.bin"), \ + patch.object(main_module, "hcatPrinceBaseList", ["/nonexistent/base.txt"]), \ + patch.object(main_module, "get_rule_path", return_value="/fake/rule.rule"), \ + patch("hate_crack.main.subprocess.Popen") as mock_popen: + main_module.hcatPrince("1000", hash_file) + + mock_popen.assert_not_called() + captured = capsys.readouterr() + assert "not found" in captured.out + + +class TestHcatRecycle: + def test_skipped_when_count_zero(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + + with patch("hate_crack.main.subprocess.Popen") as mock_popen: + main_module.hcatRecycle("1000", hash_file, 0) + + mock_popen.assert_not_called() + + def test_popen_called_when_count_nonzero(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + out_file = tmp_path / "hashes.txt.out" + out_file.write_text("hash1:password1\nhash2:password2\n") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatRules", ["best66.rule"]), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "get_rule_path", return_value="/fake/best66.rule"), \ + patch("hate_crack.main._write_delimited_field"), \ + patch("hate_crack.main.convert_hex", return_value=["password1", "password2"]), \ + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \ + patch("builtins.open", create=True) as mock_open, \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + mock_open.return_value.__enter__ = MagicMock(return_value=MagicMock()) + mock_open.return_value.__exit__ = MagicMock(return_value=False) + main_module.hcatRecycle("1000", hash_file, 5) + + assert mock_popen.call_count >= 1 + + def test_rule_path_in_cmd_when_count_nonzero(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatRules", ["best66.rule"]), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "get_rule_path", return_value="/fake/best66.rule"), \ + patch("hate_crack.main._write_delimited_field"), \ + patch("hate_crack.main.convert_hex", return_value=["pass1"]), \ + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \ + patch("builtins.open", create=True) as mock_open, \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + mock_open.return_value.__enter__ = MagicMock(return_value=MagicMock()) + mock_open.return_value.__exit__ = MagicMock(return_value=False) + main_module.hcatRecycle("1000", hash_file, 3) + + cmd = mock_popen.call_args[0][0] + assert "-r" in cmd + assert "/fake/best66.rule" in cmd + + +class TestHcatGoodMeasure: + def test_popen_called_at_least_once(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatGoodMeasureBaseList", "/fake/base.txt"), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "get_rule_path", return_value="/fake/rule.rule"), \ + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0, create=True): + main_module.hcatGoodMeasure("1000", hash_file) + + assert mock_popen.call_count >= 1 + + def test_hash_type_in_cmd(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatGoodMeasureBaseList", "/fake/base.txt"), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "get_rule_path", return_value="/fake/rule.rule"), \ + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0, create=True): + main_module.hcatGoodMeasure("500", hash_file) + + cmd = mock_popen.call_args_list[0][0][0] + assert "-m" in cmd + assert "500" in cmd + + +class TestHcatMiddleCombinator: + def test_popen_called_at_least_once(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatMiddleCombinatorMasks", ["!", "1"]), \ + patch.object(main_module, "hcatMiddleBaseList", "/fake/base.txt"), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + main_module.hcatMiddleCombinator("1000", hash_file) + + assert mock_popen.call_count >= 1 + + def test_attack_mode_1_in_cmd(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatMiddleCombinatorMasks", ["!"]), \ + patch.object(main_module, "hcatMiddleBaseList", "/fake/base.txt"), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + main_module.hcatMiddleCombinator("1000", hash_file) + + cmd = mock_popen.call_args_list[0][0][0] + assert "-a" in cmd + assert "1" in cmd + + +class TestHcatThoroughCombinator: + def test_popen_called_at_least_once(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatThoroughCombinatorMasks", ["!"]), \ + patch.object(main_module, "hcatThoroughBaseList", "/fake/base.txt"), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + main_module.hcatThoroughCombinator("1000", hash_file) + + assert mock_popen.call_count >= 1 + + +class TestHcatYoloCombination: + def test_popen_called_once_then_keyboard_interrupt(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wl_dir = tmp_path / "wordlists" + wl_dir.mkdir() + (wl_dir / "words.txt").write_text("word\n") + + mock_proc = MagicMock() + mock_proc.pid = 12345 + mock_proc.wait.side_effect = KeyboardInterrupt() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatWordlists", str(wl_dir)), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + main_module.hcatYoloCombination("1000", hash_file) + + assert mock_popen.call_count >= 1 + mock_proc.kill.assert_called() + + def test_attack_mode_1_in_cmd(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wl_dir = tmp_path / "wordlists" + wl_dir.mkdir() + (wl_dir / "words.txt").write_text("word\n") + + mock_proc = MagicMock() + mock_proc.pid = 12345 + mock_proc.wait.side_effect = KeyboardInterrupt() + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "hcatWordlists", str(wl_dir)), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + main_module.hcatYoloCombination("1000", hash_file) + + cmd = mock_popen.call_args_list[0][0][0] + assert "-a" in cmd + assert "1" in cmd + + +class TestAppendPotfileArg: + def test_appends_when_potfile_set(self, main_module): + cmd = ["hashcat", "-m", "1000"] + with patch.object(main_module, "hcatPotfilePath", "/fake/hashcat.pot"): + main_module._append_potfile_arg(cmd) + assert "--potfile-path=/fake/hashcat.pot" in cmd + + def test_no_append_when_empty(self, main_module): + cmd = ["hashcat", "-m", "1000"] + with patch.object(main_module, "hcatPotfilePath", ""): + main_module._append_potfile_arg(cmd) + assert not any(arg.startswith("--potfile-path") for arg in cmd) + + def test_no_append_when_use_potfile_false(self, main_module): + cmd = ["hashcat", "-m", "1000"] + with patch.object(main_module, "hcatPotfilePath", "/fake/hashcat.pot"): + main_module._append_potfile_arg(cmd, use_potfile_path=False) + assert not any(arg.startswith("--potfile-path") for arg in cmd) + + def test_explicit_potfile_path_overrides_global(self, main_module): + cmd = ["hashcat", "-m", "1000"] + with patch.object(main_module, "hcatPotfilePath", "/global/hashcat.pot"): + main_module._append_potfile_arg(cmd, potfile_path="/override/custom.pot") + assert "--potfile-path=/override/custom.pot" in cmd + assert "--potfile-path=/global/hashcat.pot" not in cmd diff --git a/tests/test_main_utils.py b/tests/test_main_utils.py new file mode 100644 index 0000000..24ca5f0 --- /dev/null +++ b/tests/test_main_utils.py @@ -0,0 +1,480 @@ +"""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() diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..df4a0ac --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,125 @@ +import pytest + + +class TestGetAttrProxy: + def test_known_attribute_proxied(self, hc_module): + """Accessing a known main.py global via hc_module returns main's value.""" + val = hc_module._main.debug_mode + assert hc_module.debug_mode == val + + def test_nonexistent_attribute_raises(self, hc_module): + """Accessing a nonexistent attribute raises AttributeError.""" + with pytest.raises(AttributeError): + _ = hc_module.this_attribute_does_not_exist_xyz + + def test_getattr_returns_main_function(self, hc_module): + """Functions defined in main.py are accessible via the proxy.""" + assert callable(hc_module.generate_session_id) + + +class TestSyncGlobalsToMain: + @pytest.mark.parametrize( + "name,value", + [ + ("hcatHashType", "9999"), + ("hcatHashFile", "/tmp/synced.txt"), + ("hcatHashFileOrig", "/tmp/orig.txt"), + ("pipalPath", "/tmp/pipal"), + ("pipal_count", 42), + ("debug_mode", True), + ], + ) + def test_syncs_global(self, hc_module, name, value): + """Setting a synced name in hc_module.__dict__ and calling sync propagates to main.""" + hc_module.__dict__[name] = value + hc_module._sync_globals_to_main() + assert getattr(hc_module._main, name) == value + + def test_only_syncs_listed_names(self, hc_module): + """Names not in the sync list are not pushed to main.""" + hc_module.__dict__["_random_unlisted_var"] = "should_not_sync" + hc_module._sync_globals_to_main() + assert getattr(hc_module._main, "_random_unlisted_var", None) != "should_not_sync" + + def test_absent_name_skipped(self, hc_module): + """If a synced name is absent from hc_module globals, main is not modified.""" + hc_module.__dict__.pop("pipal_count", None) + original = getattr(hc_module._main, "pipal_count", None) + hc_module._sync_globals_to_main() + assert getattr(hc_module._main, "pipal_count", None) == original + + +class TestSyncCallablesToMain: + def test_syncs_callable_to_main(self, hc_module): + """A callable set in hc_module.__dict__ is pushed to main.""" + + def fake_fn(): + return "fake" + + hc_module.__dict__["quit_hc"] = fake_fn + hc_module._sync_callables_to_main() + assert hc_module._main.quit_hc is fake_fn + + def test_syncs_show_results(self, hc_module): + """show_results callable is pushed to main when present.""" + + def fake_fn(): + return "results" + + hc_module.__dict__["show_results"] = fake_fn + hc_module._sync_callables_to_main() + assert hc_module._main.show_results is fake_fn + + def test_skips_when_not_in_globals(self, hc_module): + """If a callable name is absent from hc_module globals, main is unchanged.""" + original = getattr(hc_module._main, "show_readme", None) + hc_module.__dict__.pop("show_readme", None) + hc_module._sync_callables_to_main() + assert getattr(hc_module._main, "show_readme", None) == original + + def test_all_callable_names_sync(self, hc_module): + """All callable names in the sync list are pushed when present.""" + names = [ + "weakpass_wordlist_menu", + "download_hashmob_wordlists", + "download_hashmob_rules", + "hashview_api", + "export_excel", + "show_results", + "show_readme", + "quit_hc", + ] + + def make_fn(n: str): + def fn() -> str: + return n + + return fn + + fakes = {name: make_fn(name) for name in names} + for name, fn in fakes.items(): + hc_module.__dict__[name] = fn + hc_module._sync_callables_to_main() + for name, fn in fakes.items(): + assert getattr(hc_module._main, name) is fn + + +class TestSymbolReexport: + def test_main_function_accessible(self, hc_module): + """Functions from main.py should be accessible on hc_module.""" + assert callable(getattr(hc_module, "hcatBruteForce", None)) + + def test_hc_module_has_main_ref(self, hc_module): + """hc_module._main should be the hate_crack.main module.""" + import hate_crack.main as main_mod + + assert hc_module._main is main_mod + + def test_debug_mode_reexported(self, hc_module): + """Module-level globals from main.py appear on hc_module at load time.""" + assert hasattr(hc_module, "debug_mode") + + def test_generate_session_id_reexported(self, hc_module): + """generate_session_id from main.py is accessible directly on hc_module.""" + fn = getattr(hc_module, "generate_session_id", None) + assert callable(fn)