Files
hate_crack/tests/test_cli_flags.py
Justin Bollinger 5b0c119ec0 fix: handle Hashview create_job error response correctly
When the Hashview server returns HTTP 200 with an error message and no
job_id (due to its internal notify_email bug), the CLI and interactive
paths now:
- exit 1 (not 0) in the CLI path
- print "✗ Error" instead of "✓ Success"
- print a hint to check the Hashview UI before retrying, preventing
  duplicate job creation

Adds test for the error response path in test_cli_flags.py.
2026-03-09 13:17:50 -04:00

391 lines
13 KiB
Python

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",
],
)
assert code == 0
out = capsys.readouterr().out
assert "Hashfile uploaded" in out
assert "Job created" in out
def test_hashview_upload_hashfile_job_no_notify_email_by_default(
monkeypatch, tmp_path, capsys
):
"""CLI must not send notify_email - it causes the server to create the job but
return 'Failed to add job: notify_email is invalid', hiding the created job and
causing a second job to be created on retry."""
captured_kwargs: dict = {}
class TrackingAPI:
def __init__(self, base_url, api_key, debug=False):
pass
def upload_hashfile(
self, file_path, customer_id, hash_type, file_format=5, hashfile_name=None
):
return {"msg": "Hashfile uploaded", "hashfile_id": 456}
def create_job(
self, name, hashfile_id, customer_id, limit_recovered=False, notify_email=None
):
captured_kwargs["notify_email"] = notify_email
return {"msg": "Job created", "job_id": 789}
hashfile = tmp_path / "hashes.txt"
hashfile.write_text("hash1\n")
monkeypatch.setattr(hc_main, "HashviewAPI", TrackingAPI)
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",
],
)
assert code == 0
assert captured_kwargs["notify_email"] is None
def test_hashview_upload_hashfile_job_error_response_exits_nonzero(
monkeypatch, tmp_path, capsys
):
"""When create_job returns an error response (no job_id), exit code must be 1
and output must show ✗ Error with a hint to check the Hashview UI."""
class ErrorJobAPI:
def __init__(self, base_url, api_key, debug=False):
pass
def upload_hashfile(
self, file_path, customer_id, hash_type, file_format=5, hashfile_name=None
):
return {"msg": "Hashfile uploaded", "hashfile_id": 456}
def create_job(
self, name, hashfile_id, customer_id, limit_recovered=False, notify_email=None
):
return {
"msg": "Failed to add job: 'notify_email' is an invalid keyword argument for JobNotifications"
}
hashfile = tmp_path / "hashes.txt"
hashfile.write_text("hash1\n")
monkeypatch.setattr(hc_main, "HashviewAPI", ErrorJobAPI)
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",
],
)
assert code == 1
out = capsys.readouterr().out
assert "✗ Error" in out
assert "Job ID:" not in out
assert "Check the Hashview UI before retrying" 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"