Files
hate_crack/tests/test_hashview.py
T
Justin BollingerandClaude Opus 4.6 fa07d37a84 fix: write output files to user's CWD, not install directory
The bash shim uses `uv run --directory <install_dir>` which changes the
process CWD to the install directory. _ensure_hashfile_in_cwd() and the
Hashview download path used os.getcwd() to determine the target directory
for output files (.out, .nt, etc.), causing them to land in the install
directory instead of where the user ran the command.

Add orig_cwd() helper that reads HATE_CRACK_ORIG_CWD (set by the shim)
and use it in _ensure_hashfile_in_cwd(), the Hashview download path, and
the potfile fallback path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:43:51 -04:00

809 lines
34 KiB
Python

"""
Tests for Hashview integration - Mocked API calls for CI/CD
"""
import pytest
import sys
import os
import json
import tempfile
import uuid
from unittest.mock import Mock, patch, MagicMock
# Add the parent directory to the path to import hate_crack
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from hate_crack.api import HashviewAPI
# Test configuration - these are mock values, not real credentials
HASHVIEW_URL = "https://hashview.example.com"
HASHVIEW_API_KEY = "test-api-key-123"
class TestHashviewAPI:
"""Test suite for HashviewAPI class with mocked API calls"""
def _get_hashview_config(self):
env_url = os.environ.get("HASHVIEW_URL")
env_key = os.environ.get("HASHVIEW_API_KEY")
if env_url and env_key:
return env_url, env_key
config_path = os.path.join(os.path.dirname(__file__), "..", "config.json")
try:
with open(config_path) as f:
config = json.load(f)
url = config.get("hashview_url")
key = config.get("hashview_api_key")
if url and key:
return url, key
except Exception:
pass
return env_url, env_key
@pytest.fixture
def api(self):
"""Create a HashviewAPI instance with mocked session"""
with patch("requests.Session"):
api = HashviewAPI(base_url=HASHVIEW_URL, api_key=HASHVIEW_API_KEY)
# Replace the session with a mock
api.session = MagicMock()
yield api
@pytest.fixture
def test_hashfile(self):
"""Create a temporary test hashfile with NTLM hashes"""
test_hashes = [
"8846f7eaee8fb117ad06bdd830b7586c", # password (NTLM)
"e19ccf75ee54e06b06a5907af13cef42", # 123456 (NTLM)
"5835048ce94ad0564e29a924a03510ef", # 12345678 (NTLM)
]
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
hashfile_path = f.name
for hash_val in test_hashes:
f.write(hash_val + "\n")
yield hashfile_path
# Cleanup
def test_list_hashfiles_success(self, api):
"""Test successful hashfile listing with real API if possible, else mock."""
hashview_url, hashview_api_key = self._get_hashview_config()
if hashview_url and hashview_api_key:
real_api = HashviewAPI(hashview_url, hashview_api_key)
result = real_api.list_hashfiles()
assert isinstance(result, list)
# If there are no hashfiles, that's valid, but if present, check structure
if result:
assert "name" in result[0]
else:
mock_response = Mock()
mock_response.json.return_value = {
"hashfiles": json.dumps(
[
{"id": 1, "customer_id": 1, "name": "hashfile1.txt"},
{"id": 2, "customer_id": 2, "name": "hashfile2.txt"},
]
)
}
mock_response.raise_for_status = Mock()
api.session.get.return_value = mock_response
result = api.list_hashfiles()
assert isinstance(result, list)
assert len(result) == 2
assert result[0]["name"] == "hashfile1.txt"
def test_list_hashfiles_empty(self, api):
"""Test hashfile listing returns empty list if no hashfiles (real API if possible)."""
hashview_url, hashview_api_key = self._get_hashview_config()
if hashview_url and hashview_api_key:
real_api = HashviewAPI(hashview_url, hashview_api_key)
result = real_api.list_hashfiles()
# If there are no hashfiles, result should be []
if not result:
assert result == []
else:
assert isinstance(result, list)
else:
mock_response = Mock()
mock_response.json.return_value = {}
mock_response.raise_for_status = Mock()
api.session.get.return_value = mock_response
result = api.list_hashfiles()
assert result == []
def test_get_customer_hashfiles(self, api):
"""Test filtering hashfiles by customer_id (real API if possible)."""
hashview_url, hashview_api_key = self._get_hashview_config()
customer_id = os.environ.get("HASHVIEW_CUSTOMER_ID")
if hashview_url and hashview_api_key and customer_id:
real_api = HashviewAPI(hashview_url, hashview_api_key)
result = real_api.get_customer_hashfiles(int(customer_id))
assert isinstance(result, list)
# If there are hashfiles, all should match customer_id
if result:
assert all(hf["customer_id"] == int(customer_id) for hf in result)
else:
api.list_hashfiles = Mock(
return_value=[
{"id": 1, "customer_id": 1, "name": "hashfile1.txt"},
{"id": 2, "customer_id": 2, "name": "hashfile2.txt"},
{"id": 3, "customer_id": 1, "name": "hashfile3.txt"},
]
)
result = api.get_customer_hashfiles(1)
assert len(result) == 2
assert all(hf["customer_id"] == 1 for hf in result)
def test_display_customers_multicolumn_empty(self, api, capsys):
"""Test display_customers_multicolumn with no customers (mock only, as real API not needed)."""
api.display_customers_multicolumn([])
captured = capsys.readouterr()
assert "No customers found" in captured.out
def test_upload_cracked_hashes_success(self, api, tmp_path):
"""Test uploading cracked hashes with valid lines (real API if possible)."""
hashview_url, hashview_api_key = self._get_hashview_config()
hash_type = os.environ.get("HASHVIEW_HASH_TYPE", "1000")
if hashview_url and hashview_api_key:
real_api = HashviewAPI(hashview_url, hashview_api_key)
cracked_file = tmp_path / "cracked.txt"
cracked_file.write_text(
"8846f7eaee8fb117ad06bdd830b7586c:password\n"
"e19ccf75ee54e06b06a5907af13cef42:123456\n"
)
try:
result = real_api.upload_cracked_hashes(
str(cracked_file), hash_type=hash_type
)
assert "imported" in result
except Exception as e:
# If the API does not allow upload, skip
pytest.skip(f"Real API upload_cracked_hashes not allowed: {e}")
else:
cracked_file = tmp_path / "cracked.txt"
cracked_file.write_text(
"8846f7eaee8fb117ad06bdd830b7586c:password\n"
"e19ccf75ee54e06b06a5907af13cef42:123456\n"
"31d6cfe0d16ae931b73c59d7e0c089c0:should_skip\n"
"invalidline\n"
)
mock_response = Mock()
mock_response.json.return_value = {"imported": 2}
mock_response.raise_for_status = Mock()
api.session.post.return_value = mock_response
result = api.upload_cracked_hashes(str(cracked_file), hash_type="1000")
assert "imported" in result
assert result["imported"] == 2
def test_upload_cracked_hashes_api_error(self, api, tmp_path):
"""Test uploading cracked hashes with API error response (mock only)."""
cracked_file = tmp_path / "cracked.txt"
cracked_file.write_text("8846f7eaee8fb117ad06bdd830b7586c:password\n")
mock_response = Mock()
mock_response.json.return_value = {"type": "Error", "msg": "Some error"}
mock_response.raise_for_status = Mock()
api.session.post.return_value = mock_response
with pytest.raises(Exception) as excinfo:
api.upload_cracked_hashes(str(cracked_file), hash_type="1000")
assert "Hashview API Error" in str(excinfo.value)
def test_upload_cracked_hashes_invalid_json(self, api, tmp_path):
"""Test uploading cracked hashes with invalid JSON response (mock only)."""
cracked_file = tmp_path / "cracked.txt"
cracked_file.write_text("8846f7eaee8fb117ad06bdd830b7586c:password\n")
mock_response = Mock()
mock_response.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
mock_response.text = "not a json"
mock_response.raise_for_status = Mock()
api.session.post.return_value = mock_response
with pytest.raises(Exception) as excinfo:
api.upload_cracked_hashes(str(cracked_file), hash_type="1000")
assert "Invalid API response" in str(excinfo.value)
def test_create_customer_success(self, api):
"""Test creating a customer (real API if possible)."""
hashview_url, hashview_api_key = self._get_hashview_config()
if hashview_url and hashview_api_key:
real_api = HashviewAPI(hashview_url, hashview_api_key)
try:
result = real_api.create_customer("New Customer Test")
assert "id" in result
assert "name" in result
except Exception as e:
pytest.skip(f"Real API create_customer not allowed: {e}")
else:
mock_response = Mock()
mock_response.json.return_value = {"id": 10, "name": "New Customer"}
mock_response.raise_for_status = Mock()
api.session.post.return_value = mock_response
result = api.create_customer("New Customer")
assert result["id"] == 10
assert result["name"] == "New Customer"
def test_download_left_hashes(self, api, tmp_path):
"""Test downloading left hashes: real API if possible, else mock."""
hashview_url, hashview_api_key = self._get_hashview_config()
customer_id = os.environ.get("HASHVIEW_CUSTOMER_ID")
hashfile_id = os.environ.get("HASHVIEW_HASHFILE_ID")
if all([hashview_url, hashview_api_key, customer_id, hashfile_id]):
# Real API test
real_api = HashviewAPI(hashview_url, hashview_api_key)
output_file = tmp_path / f"left_{customer_id}_{hashfile_id}.txt"
result = real_api.download_left_hashes(
int(customer_id), int(hashfile_id), output_file=str(output_file)
)
assert os.path.exists(result["output_file"])
with open(result["output_file"], "rb") as f:
content = f.read()
print(f"[DEBUG] Downloaded {len(content)} bytes to {result['output_file']}")
assert result["size"] == len(content)
else:
# Mock test
mock_response = Mock()
mock_response.content = b"hash1\nhash2\n"
mock_response.raise_for_status = Mock()
mock_response.headers = {"content-length": "0"}
mock_response.status_code = 404 # For the found file lookup
def iter_content(chunk_size=8192):
yield mock_response.content
mock_response.iter_content = iter_content
api.session.get.return_value = mock_response
output_file = tmp_path / "left_1_2.txt"
result = api.download_left_hashes(1, 2, output_file=str(output_file))
assert os.path.exists(result["output_file"])
with open(result["output_file"], "rb") as f:
content = f.read()
assert content == b"hash1\nhash2\n"
assert result["size"] == len(content)
# Verify auth headers were passed in the left hashes download call
call_args_list = api.session.get.call_args_list
left_call = [c for c in call_args_list if "left" in str(c)][0]
assert left_call.kwargs.get("headers") is not None
auth_headers = left_call.kwargs.get("headers")
assert "Cookie" in auth_headers or "uuid" in str(auth_headers)
assert HASHVIEW_API_KEY in str(auth_headers)
def test_download_wordlist(self, api, tmp_path):
"""Test downloading a wordlist: real API if possible, else mock."""
hashview_url, hashview_api_key = self._get_hashview_config()
wordlist_id = os.environ.get("HASHVIEW_WORDLIST_ID")
if all([hashview_url, hashview_api_key, wordlist_id]):
real_api = HashviewAPI(hashview_url, hashview_api_key)
output_file = tmp_path / f"wordlist_{wordlist_id}.gz"
result = real_api.download_wordlist(
int(wordlist_id), output_file=str(output_file)
)
assert os.path.exists(result["output_file"])
with open(result["output_file"], "rb") as f:
content = f.read()
print(f"[DEBUG] Downloaded {len(content)} bytes to {result['output_file']}")
assert result["size"] == len(content)
else:
mock_response = Mock()
mock_response.content = b"gzipdata"
mock_response.raise_for_status = Mock()
mock_response.headers = {"content-length": "0"}
def iter_content(chunk_size=8192):
yield mock_response.content
mock_response.iter_content = iter_content
api.session.get.return_value = mock_response
output_file = tmp_path / "wordlist_1.gz"
result = api.download_wordlist(1, output_file=str(output_file))
assert os.path.exists(result["output_file"])
with open(result["output_file"], "rb") as f:
content = f.read()
assert content == b"gzipdata"
assert result["size"] == len(content)
# Verify auth headers were passed in the download call
# session.get should be called with headers containing the auth cookie
call_args_list = api.session.get.call_args_list
# Last call should be the download (not the update call for id 1)
download_call = [c for c in call_args_list if "wordlists/1" in str(c)][0]
assert download_call.kwargs.get("headers") is not None
auth_headers = download_call.kwargs.get("headers")
assert "Cookie" in auth_headers or "uuid" in str(auth_headers)
assert HASHVIEW_API_KEY in str(auth_headers)
def test_download_wordlist_saves_to_wordlists_dir(self, api, tmp_path):
"""When output_file is relative, it should resolve to get_hcat_wordlists_dir()."""
wordlists_dir = tmp_path / "wordlists"
wordlists_dir.mkdir()
mock_response = Mock()
mock_response.content = b"gzipdata"
mock_response.raise_for_status = Mock()
mock_response.headers = {
"content-length": "8",
"content-disposition": 'attachment; filename="mylist.txt.gz"',
}
mock_response.iter_content = lambda chunk_size=8192: iter(
[mock_response.content]
)
api.session.get.return_value = mock_response
with patch(
"hate_crack.api.get_hcat_wordlists_dir", return_value=str(wordlists_dir)
):
result = api.download_wordlist(99)
expected_path = str(wordlists_dir / "mylist.txt.gz")
assert result["output_file"] == expected_path
assert os.path.exists(expected_path)
with open(expected_path, "rb") as f:
assert f.read() == b"gzipdata"
def test_download_wordlist_absolute_path_unchanged(self, api, tmp_path):
"""When output_file is absolute, it should not be redirected."""
abs_output = str(tmp_path / "direct_output.gz")
mock_response = Mock()
mock_response.content = b"data"
mock_response.raise_for_status = Mock()
mock_response.headers = {"content-length": "4"}
mock_response.iter_content = lambda chunk_size=8192: iter(
[mock_response.content]
)
api.session.get.return_value = mock_response
result = api.download_wordlist(99, output_file=abs_output)
assert result["output_file"] == abs_output
assert os.path.exists(abs_output)
def test_list_wordlists_live(self):
"""Live test for Hashview wordlist listing with auth headers."""
# Only run this test if explicitly enabled
if os.environ.get("HASHVIEW_TEST_REAL", "").lower() not in ("1", "true", "yes"):
pytest.skip(
"Set HASHVIEW_TEST_REAL=1 to run live Hashview list_wordlists test."
)
# For live tests, prefer explicit env vars so developers don't accidentally
# hit a config.json default/localhost target.
hashview_url = os.environ.get("HASHVIEW_URL")
hashview_api_key = os.environ.get("HASHVIEW_API_KEY")
if not hashview_url or not hashview_api_key:
pytest.skip("Missing HASHVIEW_URL/HASHVIEW_API_KEY env vars.")
# Only proceed if the server is actually reachable
try:
import socket
from urllib.parse import urlparse
parsed = urlparse(hashview_url)
host = parsed.hostname
port = parsed.port
if not host:
pytest.skip(
f"Could not parse hostname from hashview_url: {hashview_url!r}"
)
if port is None:
port = 443 if parsed.scheme == "https" else 80
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
result = sock.connect_ex((host, port))
sock.close()
if result != 0:
pytest.skip(f"Hashview server not reachable at {host}:{port}")
except Exception as e:
pytest.skip(f"Could not check Hashview server availability: {e}")
real_api = HashviewAPI(hashview_url, hashview_api_key)
wordlists = real_api.list_wordlists()
assert isinstance(wordlists, list)
def test_create_job_workflow(self, api, test_hashfile):
"""Test creating a job in Hashview (option 2 complete workflow)"""
print("\n" + "=" * 60)
print("Testing Option 2: Create Job Workflow")
print("=" * 60)
# Mock responses for different endpoints - API returns 'users' as a JSON string
mock_customers_response = Mock()
mock_customers_response.json.return_value = {
"users": json.dumps([{"id": 1, "name": "Test Customer"}])
}
mock_customers_response.raise_for_status = Mock()
mock_upload_response = Mock()
mock_upload_response.json.return_value = {
"hashfile_id": 4567,
"msg": "Hashfile added",
}
mock_upload_response.raise_for_status = Mock()
mock_job_response = Mock()
mock_job_response.json.return_value = {"job_id": 789, "msg": "Job added"}
mock_job_response.raise_for_status = Mock()
# Configure session mock
api.session.get.return_value = mock_customers_response
api.session.post.side_effect = [mock_upload_response, mock_job_response]
# Step 1: Get test customer
print("\n[Step 1] Getting test customer...")
customers_result = api.list_customers()
test_customer = customers_result["customers"][0]
customer_id = test_customer["id"]
print(f" ✓ Using customer ID: {customer_id} ({test_customer['name']})")
# Step 2: Upload hashfile
print("\n[Step 2] Uploading hashfile...")
hash_type = 1000 # NTLM
file_format = 5 # hash_only
hashfile_name = "test_hashfile_automated"
upload_result = api.upload_hashfile(
test_hashfile, customer_id, hash_type, file_format, hashfile_name
)
hashfile_id = upload_result["hashfile_id"]
print(f" ✓ Hashfile ID: {hashfile_id}")
# Step 3: Create job
print("\n[Step 3] Creating job...")
job_name = "test_job_automated"
job_result = api.create_job(
name=job_name, hashfile_id=hashfile_id, customer_id=customer_id
)
assert job_result is not None, "No job result returned"
print(" ✓ Job created successfully")
if "job_id" in job_result:
print(f" ✓ Job ID: {job_result['job_id']}")
print("\n" + "=" * 60)
print("✓ Option 2 (Create Job) is READY and WORKING!")
print("=" * 60)
def test_create_job_with_new_customer(self, api, test_hashfile):
"""Test creating a new customer and then creating a job (real API if possible)."""
hashview_url, hashview_api_key = self._get_hashview_config()
hash_type = os.environ.get("HASHVIEW_HASH_TYPE", "1000")
if hashview_url and hashview_api_key:
real_api = HashviewAPI(hashview_url, hashview_api_key)
customer_name = f"Example Customer {uuid.uuid4().hex[:8]}"
try:
customer_result = real_api.create_customer(customer_name)
customer_id = customer_result.get("customer_id") or customer_result.get(
"id"
)
if not customer_id:
pytest.skip("Create customer did not return a customer_id.")
upload_result = real_api.upload_hashfile(
test_hashfile,
int(customer_id),
int(hash_type),
5,
"test_hashfile_new_customer",
)
hashfile_id = upload_result.get("hashfile_id")
if not hashfile_id:
pytest.skip("Upload hashfile did not return a hashfile_id.")
job_result = real_api.create_job(
name=f"test_job_new_customer_{uuid.uuid4().hex[:6]}",
hashfile_id=hashfile_id,
customer_id=int(customer_id),
)
if isinstance(job_result, dict) and "msg" in job_result:
msg = str(job_result.get("msg", ""))
if "Failed to add job" in msg:
pytest.xfail(f"Hashview rejected job creation: {msg}")
assert job_result is not None
if isinstance(job_result, dict):
assert "job_id" in job_result
job_id = job_result.get("job_id")
try:
real_api.start_job(job_id)
except Exception:
pass
try:
real_api.stop_job(job_id)
except Exception:
pass
try:
real_api.delete_job(job_id)
except Exception:
pass
except Exception as e:
pytest.skip(f"Real API create_job with new customer not allowed: {e}")
else:
mock_create_customer = Mock()
mock_create_customer.json.return_value = {
"customer_id": 101,
"name": "Example Customer",
}
mock_create_customer.raise_for_status = Mock()
mock_upload_hashfile = Mock()
mock_upload_hashfile.json.return_value = {
"hashfile_id": 202,
"msg": "Hashfile added",
}
mock_upload_hashfile.raise_for_status = Mock()
mock_create_job = Mock()
mock_create_job.json.return_value = {"job_id": 303, "msg": "Job added"}
mock_create_job.raise_for_status = Mock()
api.session.post.side_effect = [
mock_create_customer,
mock_upload_hashfile,
mock_create_job,
]
customer_result = api.create_customer("Example Customer")
assert customer_result.get("customer_id") == 101
upload_result = api.upload_hashfile(
test_hashfile, 101, 1000, 5, "test_hashfile_new_customer"
)
assert upload_result.get("hashfile_id") == 202
job_result = api.create_job("test_job_new_customer", 202, 101)
assert job_result.get("job_id") == 303
def test_file_format_detection(self, tmp_path):
"""Test auto-detection of hashfile formats"""
# Test pwdump format (4+ colons)
pwdump_file = tmp_path / "pwdump.txt"
pwdump_file.write_text(
"Administrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::\n"
)
# Test user:hash format (2 parts, non-hex username)
userhash_file = tmp_path / "userhash.txt"
userhash_file.write_text("user123:5f4dcc3b5aa765d61d8327deb882cf99\n")
# Test hash_only format (default)
hashonly_file = tmp_path / "hashonly.txt"
hashonly_file.write_text("5f4dcc3b5aa765d61d8327deb882cf99\n")
# Test hex:hash format (should be hash_only since first part is all hex)
hexhash_file = tmp_path / "hexhash.txt"
hexhash_file.write_text("abcdef123456:5f4dcc3b5aa765d61d8327deb882cf99\n")
# Detection logic (same as in main.py)
def detect_format(filepath):
file_format = 5 # Default to hash_only
try:
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
first_line = f.readline().strip()
if first_line:
parts = first_line.split(":")
if len(parts) >= 4:
file_format = 0 # pwdump
elif len(parts) == 2 and not all(
c in "0123456789abcdefABCDEF" for c in parts[0]
):
file_format = 4 # user:hash
except Exception:
file_format = 5
return file_format
# Verify detection
assert detect_format(pwdump_file) == 0, "Should detect pwdump format"
assert detect_format(userhash_file) == 4, "Should detect user:hash format"
assert detect_format(hashonly_file) == 5, "Should detect hash_only format"
assert detect_format(hexhash_file) == 5, "hex:hash should default to hash_only"
def test_download_left_with_auto_merge(self, api, tmp_path, monkeypatch):
"""Test that download_left automatically downloads and splits found hashes for hashcat"""
# Use a different CWD than the output directory to ensure merging uses
# output_file's directory (not os.getcwd()).
other_cwd = tmp_path / "other_cwd"
other_cwd.mkdir()
monkeypatch.chdir(other_cwd)
# Mock left hashes download
mock_left_response = Mock()
mock_left_response.content = b"uncracked_hash1\nuncracked_hash2\n"
mock_left_response.raise_for_status = Mock()
mock_left_response.headers = {"content-length": "0"}
def iter_content_left(chunk_size=8192):
yield mock_left_response.content
mock_left_response.iter_content = iter_content_left
# Mock found hashes download
mock_found_response = Mock()
mock_found_response.content = (
b"found_hash1:found_password1\nfound_hash2:found_password2\n"
)
mock_found_response.raise_for_status = Mock()
mock_found_response.headers = {"content-length": "0"}
def iter_content_found(chunk_size=8192):
yield mock_found_response.content
mock_found_response.iter_content = iter_content_found
# Set up session.get to return different responses
api.session.get.side_effect = [mock_left_response, mock_found_response]
# Mock potfile path so cleanup isn't blocked by missing ~/.hashcat dir
potfile = str(tmp_path / "hashcat.potfile")
monkeypatch.setattr("hate_crack.api.get_hcat_potfile_path", lambda: potfile)
# Download left hashes (should auto-download and split found for hashcat)
left_file = tmp_path / "left_1_2.txt"
result = api.download_left_hashes(1, 2, output_file=str(left_file))
# Verify left file was created
assert os.path.exists(result["output_file"])
# Verify left file contains only the original uncracked hashes
with open(result["output_file"], "r") as f:
left_contents = f.read()
assert "found_hash1" not in left_contents, (
"Found hashes must NOT be written back into the left file"
)
assert "found_hash2" not in left_contents, (
"Found hashes must NOT be written back into the left file"
)
assert "uncracked_hash1" in left_contents
assert "uncracked_hash2" in left_contents
# Verify found files are cleaned up after merge
found_file = tmp_path / "found_1_2.txt"
assert not os.path.exists(found_file), (
"Found file should be deleted after merge"
)
found_hashes_file = tmp_path / "found_hashes_1_2.txt"
found_clears_file = tmp_path / "found_clears_1_2.txt"
assert not os.path.exists(str(found_hashes_file)), (
"Split hashes file should be deleted after merge"
)
assert not os.path.exists(str(found_clears_file)), (
"Split clears file should be deleted after merge"
)
# Verify potfile received the found hash:plaintext pairs
with open(potfile, "r") as f:
potfile_contents = f.read()
assert "found_hash1:found_password1" in potfile_contents
assert "found_hash2:found_password2" in potfile_contents
def test_download_left_potfile_path_param_overrides_config(self, api, tmp_path):
"""Test that a passed-in potfile_path is used instead of re-reading config."""
mock_left_response = Mock()
mock_left_response.content = b"hash1\n"
mock_left_response.raise_for_status = Mock()
mock_left_response.headers = {"content-length": "0"}
mock_left_response.iter_content = lambda chunk_size=8192: iter([mock_left_response.content])
mock_found_response = Mock()
mock_found_response.content = b"found_hash:plaintext\n"
mock_found_response.raise_for_status = Mock()
mock_found_response.headers = {"content-length": "0"}
mock_found_response.iter_content = lambda chunk_size=8192: iter([mock_found_response.content])
api.session.get.side_effect = [mock_left_response, mock_found_response]
explicit_potfile = str(tmp_path / "explicit.potfile")
other_potfile = str(tmp_path / "other.potfile")
left_file = tmp_path / "left_1_2.txt"
# Pass potfile_path explicitly - config-derived path should NOT be used
with patch("hate_crack.api.get_hcat_potfile_path", return_value=other_potfile):
api.download_left_hashes(1, 2, output_file=str(left_file), potfile_path=explicit_potfile)
assert os.path.exists(explicit_potfile), "Explicit potfile should be written"
assert not os.path.exists(other_potfile), "Config-derived potfile should NOT be written"
with open(explicit_potfile, "r") as f:
assert "found_hash:plaintext" in f.read()
def test_download_left_id_matching(self, api, tmp_path):
"""Test that found hashes only merge when customer_id and hashfile_id match"""
# Create .out file with specific IDs
out_file = tmp_path / "left_1_2.txt.out"
out_file.write_text("existing_hash:password\n")
# Mock left hashes download for different IDs
mock_response = Mock()
mock_response.content = b"hash1\nhash2\n"
mock_response.raise_for_status = Mock()
mock_response.headers = {"content-length": "0"}
def iter_content(chunk_size=8192):
yield mock_response.content
mock_response.iter_content = iter_content
api.session.get.return_value = mock_response
# Download left hashes with different IDs (3_4 instead of 1_2)
left_file = tmp_path / "left_3_4.txt"
api.download_left_hashes(3, 4, output_file=str(left_file))
# Verify the different IDs' .out file wasn't affected
with open(str(out_file), "r") as f:
content = f.read()
assert content == "existing_hash:password\n", (
"Different ID's .out file should be unchanged"
)
def test_download_left_tolerates_missing_found(self, api, tmp_path):
"""Test that 404 on found hash download doesn't fail the workflow"""
# Mock successful left download
mock_left_response = Mock()
mock_left_response.content = b"hash1\nhash2\n"
mock_left_response.raise_for_status = Mock()
mock_left_response.headers = {"content-length": "0"}
def iter_content(chunk_size=8192):
yield mock_left_response.content
mock_left_response.iter_content = iter_content
# Mock 404 response for found download
from requests.exceptions import HTTPError
mock_found_response = Mock()
mock_found_response.status_code = 404
def raise_404():
response = Mock()
response.status_code = 404
raise HTTPError("404 Not Found", response=response)
mock_found_response.raise_for_status = raise_404
# Set up session.get to return different responses
api.session.get.side_effect = [mock_left_response, mock_found_response]
# Download left hashes (should complete despite 404 on found)
left_file = tmp_path / "left_1_2.txt"
result = api.download_left_hashes(1, 2, output_file=str(left_file))
# Verify left file was created successfully
assert os.path.exists(result["output_file"])
with open(result["output_file"], "rb") as f:
content = f.read()
assert content == b"hash1\nhash2\n"
def test_hashfile_orig_path_preservation(self, tmp_path, monkeypatch):
"""Test that original hashfile path is preserved before _ensure_hashfile_in_cwd"""
from hate_crack.main import _ensure_hashfile_in_cwd
# Create a test hashfile in a different directory
test_dir = tmp_path / "subdir"
test_dir.mkdir()
test_file = test_dir / "test.txt"
test_file.write_text("hash1\nhash2\n")
original_path = str(test_file)
# Set HATE_CRACK_ORIG_CWD so _ensure_hashfile_in_cwd targets tmp_path
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(tmp_path))
# Call _ensure_hashfile_in_cwd
result_path = _ensure_hashfile_in_cwd(original_path)
# The result should be different from original (in cwd now)
# But original_path should still exist and be unchanged
assert os.path.exists(original_path), "Original file should still exist"
assert os.path.exists(result_path), "Result file should exist"
# If they're different, result should be in cwd
if result_path != original_path:
assert os.path.dirname(result_path) == str(tmp_path), (
"Result should be in cwd"
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])