Files
hate_crack/tests/test_hcat_ollama.py
T
Justin BollingerandClaude 549c5a0a64 fix(llm): bound Ollama requests with a configurable timeout
The Atomic Agents client was built with no timeout, so an Ollama server that
accepted the TCP connection but never replied (most commonly a large model still
loading into VRAM) left the CLI blocked in agent.run() forever. The caller's
`except Exception` handler only ever fired on connection-refused.

- llm.generate_candidates() takes a `timeout` parameter (default
  DEFAULT_TIMEOUT_SECONDS = 300.0) and forwards it to the OpenAI client.
- openai.APITimeoutError is translated into a new domain-level
  llm.LLMTimeoutError so main.py need not import openai itself, keeping the
  atomic-agents/instructor dependency isolated to llm.py as documented.
- hcatOllama passes the new `ollamaTimeout` config value and prints
  timeout-specific guidance (elapsed seconds, VRAM-loading hint, the setting to
  raise) instead of the misleading "ensure Ollama is running" message.
- Documented `ollamaTimeout` in config.json.example and README.

Also closes two test gaps: the defensive `except ValueError` handler in
hcatOllama now has coverage, and the misleadingly-named `test_unknown_mode_raises`
is split into explicit unit tests of _build_request's mode validation. Ticked the
completed steps in the LLM Atomic Agents plan doc.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 17:17:58 -04:00

231 lines
9.4 KiB
Python

"""Orchestration tests for hcatOllama (candidate generation is mocked)."""
import os
from types import SimpleNamespace
from contextlib import contextmanager
from unittest import mock
import pytest
os.environ["HATE_CRACK_SKIP_INIT"] = "1"
from hate_crack import main as hc_main # noqa: E402
OLLAMA_URL = "http://localhost:11434"
MODEL = "qwen2.5:32b"
@pytest.fixture
def ollama_env(tmp_path):
hash_file = tmp_path / "hashes.txt"
hash_file.touch()
wordlist = tmp_path / "sample.txt"
wordlist.write_text("password\n123456\nletmein\n")
return SimpleNamespace(
tmp_path=tmp_path, hash_file=str(hash_file), wordlist=str(wordlist)
)
@contextmanager
def ollama_globals(tmp_path, tuning="", potfile=""):
rules_dir = str(tmp_path / "rules")
os.makedirs(rules_dir, exist_ok=True)
with mock.patch.object(hc_main, "ollamaUrl", OLLAMA_URL), \
mock.patch.object(hc_main, "ollamaModel", MODEL), \
mock.patch.object(hc_main, "ollamaNumCtx", 2048), \
mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \
mock.patch.object(hc_main, "hcatTuning", tuning), \
mock.patch.object(hc_main, "hcatPotfilePath", potfile), \
mock.patch.object(hc_main, "rulesDirectory", rules_dir), \
mock.patch("hate_crack.main.generate_session_id", return_value="s"):
yield
def _make_proc(wait_return=0):
proc = mock.MagicMock()
proc.wait.return_value = wait_return
proc.communicate.return_value = (b"", b"")
proc.returncode = wait_return
return proc
def test_pull_ollama_model_is_gone():
assert not hasattr(hc_main, "_pull_ollama_model")
def test_target_mode_passes_dict_through(ollama_env):
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["Password1"]) as gen, \
mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama(
"0", ollama_env.hash_file, "target",
{"company": "ACME", "industry": "tech", "location": "NYC"},
)
gen.assert_called_once()
args = gen.call_args[0]
assert args[0] == OLLAMA_URL and args[1] == MODEL and args[3] == "target"
assert args[4] == {"company": "ACME", "industry": "tech", "location": "NYC"}
def test_wordlist_mode_reads_file_and_passes_sample(ollama_env):
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["Password1"]) as gen, \
mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", ollama_env.hash_file, "wordlist", ollama_env.wordlist)
ctx_data = gen.call_args[0][4]
assert "letmein" in ctx_data["sample"]
def test_wordlist_mode_strips_hash_prefix(ollama_env):
# hash:password lines should contribute only the post-colon plaintext.
dump = ollama_env.tmp_path / "dump.txt"
dump.write_text("aad3b435:Winter2024\nnocolonline\n")
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["x"]) as gen, \
mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", ollama_env.hash_file, "wordlist", str(dump))
sample = gen.call_args[0][4]["sample"]
assert "Winter2024" in sample
assert "aad3b435" not in sample
assert "nocolonline" in sample
def test_per_rule_runs_hashcat_with_rule_flag(ollama_env):
rules_dir = str(ollama_env.tmp_path / "rules")
os.makedirs(rules_dir, exist_ok=True)
rule_file = os.path.join(rules_dir, "test.rule")
open(rule_file, "w").close()
calls = []
def track_popen(cmd, **kwargs):
calls.append(list(cmd))
return _make_proc()
with mock.patch.object(hc_main, "ollamaUrl", OLLAMA_URL), \
mock.patch.object(hc_main, "ollamaModel", MODEL), \
mock.patch.object(hc_main, "ollamaNumCtx", 2048), \
mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \
mock.patch.object(hc_main, "hcatTuning", ""), \
mock.patch.object(hc_main, "hcatPotfilePath", ""), \
mock.patch.object(hc_main, "rulesDirectory", rules_dir), \
mock.patch("hate_crack.main.generate_session_id", return_value="s"), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["Password1"]), \
mock.patch("subprocess.Popen", side_effect=track_popen):
hc_main.hcatOllama("1000", ollama_env.hash_file, "target",
{"company": "X", "industry": "Y", "location": "Z"})
# call 0 = plain wordlist run, call 1 = rule run
assert len(calls) == 2
rule_call = calls[1]
assert "-r" in rule_call
assert rule_file in rule_call
def test_missing_wordlist_prints_error(ollama_env, capsys):
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates") as gen:
hc_main.hcatOllama("0", ollama_env.hash_file, "wordlist", "/no/such.txt")
captured = capsys.readouterr()
assert "Wordlist not found" in captured.out
gen.assert_not_called()
def test_writes_candidates_and_runs_hashcat(ollama_env):
calls = []
def track_popen(cmd, **kwargs):
calls.append(list(cmd))
return _make_proc()
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["Password1", "Summer2024"]), \
mock.patch("subprocess.Popen", side_effect=track_popen):
hc_main.hcatOllama("1000", ollama_env.hash_file, "target",
{"company": "X", "industry": "Y", "location": "Z"})
candidates_path = f"{ollama_env.hash_file}.ollama_candidates"
assert os.path.isfile(candidates_path)
with open(candidates_path) as f:
assert f.read().splitlines() == ["Password1", "Summer2024"]
# First hashcat call is the plain wordlist run with the candidates file.
assert calls and candidates_path in calls[0]
assert "-r" not in calls[0]
def test_empty_candidates_skips_hashcat(ollama_env, capsys):
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates", return_value=[]), \
mock.patch("subprocess.Popen") as popen:
hc_main.hcatOllama("0", ollama_env.hash_file, "target",
{"company": "X", "industry": "Y", "location": "Z"})
captured = capsys.readouterr()
assert "no usable" in captured.out.lower()
popen.assert_not_called()
def test_generation_error_reports_and_aborts(ollama_env, capsys):
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
side_effect=Exception("connection refused")), \
mock.patch("subprocess.Popen") as popen:
hc_main.hcatOllama("0", ollama_env.hash_file, "target",
{"company": "X", "industry": "Y", "location": "Z"})
captured = capsys.readouterr()
assert "Ensure Ollama is running" in captured.out
popen.assert_not_called()
def test_timeout_error_reports_timeout_guidance(ollama_env, capsys):
with ollama_globals(ollama_env.tmp_path), \
mock.patch.object(hc_main, "ollamaTimeout", 300.0), \
mock.patch("hate_crack.main.llm.generate_candidates",
side_effect=hc_main.llm.LLMTimeoutError("timed out")), \
mock.patch("subprocess.Popen") as popen:
hc_main.hcatOllama("0", ollama_env.hash_file, "target",
{"company": "X", "industry": "Y", "location": "Z"})
captured = capsys.readouterr()
assert "timed out" in captured.out.lower()
assert "300" in captured.out
assert "ollamaTimeout" in captured.out
assert "Ensure Ollama is running" not in captured.out
popen.assert_not_called()
assert not os.path.isfile(f"{ollama_env.hash_file}.ollama_candidates")
def test_value_error_reports_message_and_aborts(ollama_env, capsys):
"""Covers the defensive `except ValueError` handler in hcatOllama."""
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
side_effect=ValueError("boom")), \
mock.patch("subprocess.Popen") as popen:
hc_main.hcatOllama("0", ollama_env.hash_file, "target",
{"company": "X", "industry": "Y", "location": "Z"})
captured = capsys.readouterr()
assert "Error: boom" in captured.out
popen.assert_not_called()
assert not os.path.isfile(f"{ollama_env.hash_file}.ollama_candidates")
def test_timeout_config_forwarded_to_generate_candidates(ollama_env):
with ollama_globals(ollama_env.tmp_path), \
mock.patch.object(hc_main, "ollamaTimeout", 77.0), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["Password1"]) as gen, \
mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", ollama_env.hash_file, "target",
{"company": "X", "industry": "Y", "location": "Z"})
assert gen.call_args.kwargs["timeout"] == 77.0
def test_unknown_mode_prints_error(ollama_env, capsys):
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates") as gen:
hc_main.hcatOllama("0", ollama_env.hash_file, "bogus", {})
captured = capsys.readouterr()
assert "Unknown LLM generation mode" in captured.out
gen.assert_not_called()