diff --git a/tests/test_cli_flags.py b/tests/test_cli_flags.py new file mode 100644 index 0000000..2d2a693 --- /dev/null +++ b/tests/test_cli_flags.py @@ -0,0 +1,295 @@ +import sys + +import pytest + +import hate_crack.main as hc_main + + +def _run_main(monkeypatch, argv): + """Set sys.argv and run hc_main.main(), returning the exit code.""" + monkeypatch.setattr(sys, "argv", ["hate_crack.py"] + argv) + with pytest.raises(SystemExit) as exc: + hc_main.main() + return exc.value.code + + +# --------------------------------------------------------------------------- +# 1. Top-level flags that dispatch to a handler and exit 0 +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + "argv, handler_attr", + [ + (["--weakpass"], "weakpass_wordlist_menu"), + (["--hashmob"], "download_hashmob_wordlists"), + (["--rules"], "download_hashmob_rules"), + (["--cleanup"], "cleanup_wordlist_artifacts"), + ], +) +def test_flag_dispatches_to_handler(monkeypatch, capsys, argv, handler_attr): + called = [] + monkeypatch.setattr( + hc_main, + handler_attr, + lambda **kw: called.append(kw) or None, + ) + code = _run_main(monkeypatch, argv) + assert code == 0 + assert len(called) == 1 + + +def test_download_torrent_flag(monkeypatch, capsys): + called = [] + monkeypatch.setattr( + hc_main, + "download_weakpass_torrent", + lambda download_torrent, filename, print_fn=print: called.append(filename), + ) + code = _run_main(monkeypatch, ["--download-torrent", "somefile.txt"]) + assert code == 0 + assert called == ["somefile.txt"] + + +def test_download_all_torrents_flag(monkeypatch): + called = [] + monkeypatch.setattr( + hc_main, + "download_all_weakpass_torrents", + lambda fetch_all_wordlists, download_torrent, print_fn=print: called.append( + True + ), + ) + code = _run_main(monkeypatch, ["--download-all-torrents"]) + assert code == 0 + assert called == [True] + + +def test_hashview_flag(monkeypatch): + monkeypatch.setattr(hc_main, "hashview_api_key", "dummy") + called = [] + monkeypatch.setattr(hc_main, "hashview_api", lambda: called.append(True)) + code = _run_main(monkeypatch, ["--hashview"]) + assert code == 0 + assert called == [True] + + +def test_download_hashview_flag(monkeypatch): + """--download-hashview falls into the menu loop, triggers hashview_api, then exits.""" + monkeypatch.setattr(hc_main, "hashview_api_key", "dummy") + called = [] + monkeypatch.setattr(hc_main, "hashview_api", lambda: called.append(True)) + monkeypatch.setattr(hc_main, "ascii_art", lambda: None) + monkeypatch.setattr("builtins.input", lambda _prompt="": "1") + code = _run_main(monkeypatch, ["--download-hashview"]) + assert code == 0 + assert called == [True] + + +# --------------------------------------------------------------------------- +# 2. --weakpass --rank passthrough +# --------------------------------------------------------------------------- +def test_weakpass_rank_passthrough(monkeypatch): + called = [] + monkeypatch.setattr( + hc_main, + "weakpass_wordlist_menu", + lambda **kw: called.append(kw), + ) + code = _run_main(monkeypatch, ["--weakpass", "--rank", "5"]) + assert code == 0 + assert called == [{"rank": 5}] + + +def test_weakpass_default_rank(monkeypatch): + called = [] + monkeypatch.setattr( + hc_main, + "weakpass_wordlist_menu", + lambda **kw: called.append(kw), + ) + code = _run_main(monkeypatch, ["--weakpass"]) + assert code == 0 + assert called == [{"rank": -1}] + + +# --------------------------------------------------------------------------- +# 3. --potfile-path and --no-potfile-path +# --------------------------------------------------------------------------- +def test_potfile_path_flag(monkeypatch): + monkeypatch.setattr(hc_main, "ascii_art", lambda: None) + monkeypatch.setattr("builtins.input", lambda _prompt="": "5") + _run_main(monkeypatch, ["--potfile-path", "/tmp/test.pot"]) + assert hc_main.hcatPotfilePath == "/tmp/test.pot" + + +def test_no_potfile_path_flag(monkeypatch): + monkeypatch.setattr(hc_main, "ascii_art", lambda: None) + monkeypatch.setattr("builtins.input", lambda _prompt="": "5") + _run_main(monkeypatch, ["--no-potfile-path"]) + assert hc_main.hcatPotfilePath == "" + + +def test_potfile_path_empty_string_reverts_to_default(monkeypatch): + monkeypatch.setattr(hc_main, "ascii_art", lambda: None) + monkeypatch.setattr("builtins.input", lambda _prompt="": "5") + _run_main(monkeypatch, ["--potfile-path", ""]) + assert hc_main.hcatPotfilePath == "" + + +# --------------------------------------------------------------------------- +# 4. --debug flag +# --------------------------------------------------------------------------- +def test_debug_flag(monkeypatch): + monkeypatch.setattr(hc_main, "ascii_art", lambda: None) + monkeypatch.setattr("builtins.input", lambda _prompt="": "5") + _run_main(monkeypatch, ["--debug"]) + assert hc_main.debug_mode is True + + +# --------------------------------------------------------------------------- +# 5. Positional arguments: hashfile and hashtype +# --------------------------------------------------------------------------- +def test_positional_hashfile_and_hashtype(monkeypatch, tmp_path): + hashfile = tmp_path / "hashes.txt" + hashfile.write_text("aabbccdd\n") + monkeypatch.setattr(hc_main, "ascii_art", lambda: None) + # Mock the main menu loop to prevent interactive prompts after globals are set + monkeypatch.setattr( + hc_main, "get_main_menu_options", lambda: {"q": ("Quit", lambda: sys.exit(0))} + ) + monkeypatch.setattr("builtins.input", lambda _prompt="": "q") + _run_main(monkeypatch, [str(hashfile), "1000"]) + assert hc_main.hcatHashType == "1000" + assert hc_main.hcatHashFile is not None + + +def test_positional_hashfile_only_enters_menu(monkeypatch, tmp_path): + """With only hashfile (no hashtype), falls through to the interactive menu.""" + hashfile = tmp_path / "hashes.txt" + hashfile.write_text("aabbccdd\n") + monkeypatch.setattr(hc_main, "ascii_art", lambda: None) + monkeypatch.setattr("builtins.input", lambda _prompt="": "5") + code = _run_main(monkeypatch, [str(hashfile)]) + assert code == 0 + + +def test_no_args_enters_menu(monkeypatch): + """No arguments falls through to the interactive menu.""" + monkeypatch.setattr(hc_main, "ascii_art", lambda: None) + monkeypatch.setattr("builtins.input", lambda _prompt="": "5") + code = _run_main(monkeypatch, []) + assert code == 0 + + +# --------------------------------------------------------------------------- +# 6. Hashview subcommand: download-hashes +# --------------------------------------------------------------------------- +class DummyHashviewAPI: + def __init__(self, base_url, api_key, debug=False): + self.calls = [] + + def download_left_hashes(self, customer_id, hashfile_id, hash_type=None): + self.calls.append( + ("download_left_hashes", customer_id, hashfile_id, hash_type) + ) + return {"output_file": "left.txt", "size": 42} + + +def test_hashview_download_hashes(monkeypatch, capsys): + monkeypatch.setattr(hc_main, "HashviewAPI", DummyHashviewAPI) + monkeypatch.setattr(hc_main, "hashview_api_key", "dummy") + monkeypatch.setattr(hc_main, "hashview_url", "https://hv.example.com") + code = _run_main( + monkeypatch, + [ + "hashview", + "download-hashes", + "--customer-id", + "1", + "--hashfile-id", + "2", + "--hash-type", + "1000", + ], + ) + assert code == 0 + out = capsys.readouterr().out + assert "42 bytes" in out + + +# --------------------------------------------------------------------------- +# 7. Hashview upload-hashfile-job with --limit-recovered and --no-notify-email +# --------------------------------------------------------------------------- +class DummyHashviewAPIFull: + def __init__(self, base_url, api_key, debug=False): + self.calls = [] + + def upload_hashfile( + self, file_path, customer_id, hash_type, file_format=5, hashfile_name=None + ): + self.calls.append(("upload_hashfile", file_path)) + return {"msg": "Hashfile uploaded", "hashfile_id": 456} + + def create_job( + self, name, hashfile_id, customer_id, limit_recovered=False, notify_email=True + ): + self.calls.append( + ("create_job", limit_recovered, notify_email) + ) + return {"msg": "Job created", "job_id": 789} + + +def test_hashview_upload_hashfile_job_flags(monkeypatch, tmp_path, capsys): + hashfile = tmp_path / "hashes.txt" + hashfile.write_text("hash1\n") + monkeypatch.setattr(hc_main, "HashviewAPI", DummyHashviewAPIFull) + monkeypatch.setattr(hc_main, "hashview_api_key", "dummy") + monkeypatch.setattr(hc_main, "hashview_url", "https://hv.example.com") + code = _run_main( + monkeypatch, + [ + "hashview", + "upload-hashfile-job", + "--file", + str(hashfile), + "--customer-id", + "1", + "--hash-type", + "1000", + "--job-name", + "TestJob", + "--limit-recovered", + "--no-notify-email", + ], + ) + assert code == 0 + out = capsys.readouterr().out + assert "Hashfile uploaded" in out + assert "Job created" in out + + +# --------------------------------------------------------------------------- +# 8. Argparse error cases (exit 2) +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + "argv", + [ + ["--download-torrent"], # missing filename + ["hashview", "upload-cracked"], # missing --file + ["--potfile-path"], # missing path argument + ], +) +def test_argparse_missing_required_args(monkeypatch, argv): + monkeypatch.setattr(hc_main, "hashview_api_key", "dummy") + monkeypatch.setattr(hc_main, "hashview_url", "https://hv.example.com") + code = _run_main(monkeypatch, argv) + assert code == 2 + + +def test_potfile_path_and_no_potfile_path_conflict(monkeypatch): + """Both --potfile-path and --no-potfile-path should still parse (not mutually exclusive in argparse).""" + monkeypatch.setattr(hc_main, "ascii_art", lambda: None) + monkeypatch.setattr("builtins.input", lambda _prompt="": "5") + # --potfile-path wins because it's checked second in the dispatch logic + code = _run_main(monkeypatch, ["--potfile-path", "/tmp/test.pot", "--no-potfile-path"]) + assert code == 0 + assert hc_main.hcatPotfilePath == "/tmp/test.pot"