lots of refactoring around the menues and building out test cases

This commit is contained in:
Justin Bollinger
2026-02-05 13:52:06 -05:00
parent bbaa40958a
commit 9756f83b0c
32 changed files with 2458 additions and 1236 deletions
+8 -2
View File
@@ -25,13 +25,19 @@ jobs:
- name: Install project dependencies
run: |
uv venv .venv
uv pip install --python .venv/bin/python pytest
uv pip install --python .venv/bin/python pytest pytest-cov ruff mypy
uv pip install --python .venv/bin/python .
- name: Run ruff
run: .venv/bin/ruff check hate_crack
- name: Run mypy
run: .venv/bin/mypy --ignore-missing-imports hate_crack
- name: Run tests
env:
HATE_CRACK_RUN_E2E: "0"
HATE_CRACK_RUN_DOCKER_TESTS: "0"
HATE_CRACK_RUN_LIVE_TESTS: "0"
HATE_CRACK_SKIP_INIT: "1"
run: .venv/bin/python -m pytest -v
run: .venv/bin/python -m pytest
+4 -1
View File
@@ -1,5 +1,5 @@
.DEFAULT_GOAL := submodules
.PHONY: install reinstall clean hashcat-utils submodules test
.PHONY: install reinstall clean hashcat-utils submodules test coverage
hashcat-utils: submodules
$(MAKE) -C hashcat-utils
@@ -59,6 +59,9 @@ clean:
test:
uv run pytest -v
coverage:
uv run pytest --cov=hate_crack --cov-report=term-missing
uninstall:
@echo "Detecting OS and uninstalling dependencies..."
+66
View File
@@ -220,6 +220,8 @@ make test
Common options:
- `--download-hashview`: Download hashes from Hashview before cracking.
- `--hashview`: Interactive Hashview menu for managing hashes, wordlists, and jobs.
- `--hashview --help`: Show Hashview command-line options.
- `--weakpass`: Download wordlists from Weakpass.
- `--hashmob`: Download wordlists from Hashmob.net.
- `--download-torrent <FILENAME>`: Download a specific Weakpass torrent file.
@@ -230,6 +232,70 @@ Common options:
- `--bandrel-basewords <PATH>`: Override bandrel basewords file.
- `--debug`: Enable debug logging (writes `hate_crack.log` in repo root).
### Hashview Integration
hate_crack integrates with Hashview for centralized hash management and distributed cracking.
#### Interactive Menu
Access the interactive Hashview menu:
```bash
hate_crack.py --hashview
```
Menu options:
- **(1) Upload Cracked Hashes** - Upload cracked results from current session to Hashview
- **(2) Upload Wordlist** - Upload a wordlist file to Hashview
- **(3) Download Wordlist** - Download a wordlist from Hashview
- **(4) Download Left Hashes** - Download remaining uncracked hashes (automatically merges with found hashes if available)
- **(5) Upload Hashfile and Create Job** - Upload new hashfile and create a cracking job
- **(99) Back to Main Menu** - Return to main menu
#### Command-Line Interface
Hashview operations can also be performed via command-line:
Upload cracked hashes:
```bash
hate_crack.py --hashview upload-cracked --file <output_file>.out --hash-type 1000
```
Upload a wordlist:
```bash
hate_crack.py --hashview upload-wordlist --file <wordlist>.txt --name "My Wordlist"
```
Download left hashes (automatically merges with found hashes):
```bash
hate_crack.py --hashview download-left --customer-id 1 --hashfile-id 123
```
Upload hashfile and create job:
```bash
hate_crack.py --hashview upload-hashfile-job --file hashes.txt --customer-id 1 \
--hash-type 1000 --job-name "NTLM Crack Job" --hashfile-name "Domain Hashes"
```
#### Configuration
Set Hashview credentials in `config.json`:
```json
{
"hashview_url": "https://hashview.example.com",
"hashview_api_key": "your-api-key-here"
}
```
#### Automatic Found Hash Merging
When downloading left hashes, hate_crack automatically:
1. Attempts to download any found (cracked) hashes from Hashview
2. Merges found hashes with local `.out` files (e.g., `left_1_123.txt.out` or `left_1_123.nt.txt.out` for pwdump format)
3. Removes duplicate entries
4. Deletes the temporary found file after merging
This ensures your local cracking results stay synchronized with Hashview's centralized database.
The <hash_type> is attained by running `hashcat --help`
Example Hashes: http://hashcat.net/wiki/doku.php?id=example_hashes
+6
View File
@@ -136,6 +136,12 @@ uv run pytest -v
# Or via Makefile
make test
# Run tests with coverage
uv run pytest --cov=hate_crack --cov-report=term-missing
# Or via Makefile
make coverage
# Run specific test
uv run pytest tests/test_hashview.py -v
+8 -1
View File
@@ -5,7 +5,14 @@ from hate_crack import main as _main
# Re-export symbols for tests and legacy imports.
for _name, _value in _main.__dict__.items():
if _name.startswith("__") and _name not in {"__all__", "__doc__", "__name__", "__package__", "__loader__", "__spec__"}:
if _name.startswith("__") and _name not in {
"__all__",
"__doc__",
"__name__",
"__package__",
"__loader__",
"__spec__",
}:
continue
globals().setdefault(_name, _value)
+1 -1
View File
@@ -1 +1 @@
# hate_crack package
# hate_crack package
+525 -244
View File
File diff suppressed because it is too large Load Diff
+69 -46
View File
@@ -6,8 +6,9 @@ from typing import Any
from hate_crack.api import download_hashmob_rules
from hate_crack.formatting import print_multicolumn_list
def _configure_readline(completer):
readline.set_completer_delims(' \t\n;')
readline.set_completer_delims(" \t\n;")
try:
readline.parse_and_bind("set completion-query-items -1")
except Exception:
@@ -31,7 +32,9 @@ def quick_crack(ctx: Any) -> None:
wordlist_files = sorted(
f for f in os.listdir(ctx.hcatWordlists) if f != ".DS_Store"
)
wordlist_entries = [f"{i}. {file}" for i, file in enumerate(wordlist_files, start=1)]
wordlist_entries = [
f"{i}. {file}" for i, file in enumerate(wordlist_files, start=1)
]
max_entry_len = max((len(e) for e in wordlist_entries), default=24)
print_multicolumn_list(
"Wordlists",
@@ -42,14 +45,19 @@ def quick_crack(ctx: Any) -> None:
def path_completer(text, state):
if not text:
text = './'
text = "./"
text = os.path.expanduser(text)
if text.startswith('/') or text.startswith('./') or text.startswith('../') or text.startswith('~'):
matches = glob.glob(text + '*')
if (
text.startswith("/")
or text.startswith("./")
or text.startswith("../")
or text.startswith("~")
):
matches = glob.glob(text + "*")
else:
matches = glob.glob('./' + text + '*')
matches = [m[2:] if m.startswith('./') else m for m in matches]
matches = [m + '/' if os.path.isdir(m) else m for m in matches]
matches = glob.glob("./" + text + "*")
matches = [m[2:] if m.startswith("./") else m for m in matches]
matches = [m + "/" if os.path.isdir(m) else m for m in matches]
try:
return matches[state]
except IndexError:
@@ -64,10 +72,12 @@ def quick_crack(ctx: Any) -> None:
f"Press Enter for default optimized wordlists [{ctx.hcatOptimizedWordlists}]: "
)
raw_choice = raw_choice.strip()
if raw_choice == '':
if raw_choice == "":
wordlist_choice = ctx.hcatOptimizedWordlists
elif raw_choice.isdigit() and 1 <= int(raw_choice) <= len(wordlist_files):
chosen = os.path.join(ctx.hcatWordlists, wordlist_files[int(raw_choice) - 1])
chosen = os.path.join(
ctx.hcatWordlists, wordlist_files[int(raw_choice) - 1]
)
if os.path.exists(chosen):
wordlist_choice = chosen
print(wordlist_choice)
@@ -75,28 +85,32 @@ def quick_crack(ctx: Any) -> None:
wordlist_choice = raw_choice
else:
wordlist_choice = None
print('Please enter a valid wordlist or wordlist directory.')
print("Please enter a valid wordlist or wordlist directory.")
except ValueError:
print("Please enter a valid number.")
rule_files = sorted(
f for f in os.listdir(ctx.hcatPath + '/rules') if f != ".DS_Store"
f for f in os.listdir(ctx.rulesDirectory) if f != ".DS_Store"
)
if not rule_files:
download_rules = input(
"\nNo rules found. Download rules from Hashmob now? (Y/n): "
).strip().lower()
download_rules = (
input("\nNo rules found. Download rules from Hashmob now? (Y/n): ")
.strip()
.lower()
)
if download_rules in ("", "y", "yes"):
download_hashmob_rules(print_fn=print)
rule_files = sorted(os.listdir(ctx.hcatPath + '/rules'))
rule_files = sorted(os.listdir(ctx.rulesDirectory))
if not rule_files:
print("No rules available. Proceeding without rules.")
rule_choice = ['0']
rule_choice = ["0"]
else:
print("\nWhich rule(s) would you like to run?")
rule_entries = ["0. To run without any rules"]
rule_entries.extend([f"{i}. {file}" for i, file in enumerate(rule_files, start=1)])
rule_entries.extend(
[f"{i}. {file}" for i, file in enumerate(rule_files, start=1)]
)
rule_entries.append("98. YOLO...run all of the rules")
rule_entries.append("99. Back to Main Menu")
max_rule_len = max((len(e) for e in rule_entries), default=26)
@@ -109,49 +123,51 @@ def quick_crack(ctx: Any) -> None:
example_line = ""
if len(rule_files) >= 2:
example_line = (
f'For example 1+1 will run {rule_files[0]} chained twice and 1,2 would run {rule_files[0]} and then {rule_files[1]} sequentially.\n'
)
example_line = f"For example 1+1 will run {rule_files[0]} chained twice and 1,2 would run {rule_files[0]} and then {rule_files[1]} sequentially.\n"
elif len(rule_files) == 1:
example_line = f'For example 1+1 will run {rule_files[0]} chained twice.\n'
example_line = f"For example 1+1 will run {rule_files[0]} chained twice.\n"
while rule_choice is None:
raw_choice = input(
'Enter Comma separated list of rules you would like to run. To run rules chained use the + symbol.\n'
f'{example_line}'
'Choose wisely: '
"Enter Comma separated list of rules you would like to run. To run rules chained use the + symbol.\n"
f"{example_line}"
"Choose wisely: "
)
if raw_choice.strip() == '99':
if raw_choice.strip() == "99":
return
if raw_choice != '':
rule_choice = raw_choice.split(',')
if raw_choice != "":
rule_choice = raw_choice.split(",")
if '99' in rule_choice:
if "99" in rule_choice:
return
if '98' in rule_choice:
if "98" in rule_choice:
for rule in rule_files:
selected_hcatRules.append(f"-r {ctx.hcatPath}/rules/{rule}")
elif '0' in rule_choice:
selected_hcatRules = ['']
selected_hcatRules.append(f"-r {ctx.rulesDirectory}/{rule}")
elif "0" in rule_choice:
selected_hcatRules = [""]
else:
for choice in rule_choice:
if '+' in choice:
combined_choice = ''
choices = choice.split('+')
if "+" in choice:
combined_choice = ""
choices = choice.split("+")
for rule in choices:
try:
combined_choice = f"{combined_choice} -r {ctx.hcatPath}/rules/{rule_files[int(rule) - 1]}"
combined_choice = f"{combined_choice} -r {ctx.rulesDirectory}/{rule_files[int(rule) - 1]}"
except Exception:
continue
selected_hcatRules.append(combined_choice)
else:
try:
selected_hcatRules.append(f"-r {ctx.hcatPath}/rules/{rule_files[int(choice) - 1]}")
selected_hcatRules.append(
f"-r {ctx.rulesDirectory}/{rule_files[int(choice) - 1]}"
)
except IndexError:
continue
for chain in selected_hcatRules:
ctx.hcatQuickDictionary(ctx.hcatHashType, ctx.hcatHashFile, chain, wordlist_choice)
ctx.hcatQuickDictionary(
ctx.hcatHashType, ctx.hcatHashFile, chain, wordlist_choice
)
def extensive_crack(ctx: Any) -> None:
@@ -173,13 +189,19 @@ def extensive_crack(ctx: Any) -> None:
def brute_force_crack(ctx: Any) -> None:
hcatMinLen = int(input("\nEnter the minimum password length to brute force (1): ") or 1)
hcatMaxLen = int(input("\nEnter the maximum password length to brute force (7): ") or 7)
hcatMinLen = int(
input("\nEnter the minimum password length to brute force (1): ") or 1
)
hcatMaxLen = int(
input("\nEnter the maximum password length to brute force (7): ") or 7
)
ctx.hcatBruteForce(ctx.hcatHashType, ctx.hcatHashFile, hcatMinLen, hcatMaxLen)
def top_mask_crack(ctx: Any) -> None:
hcatTargetTime = int(input("\nEnter a target time for completion in hours (4): ") or 4)
hcatTargetTime = int(
input("\nEnter a target time for completion in hours (4): ") or 4
)
hcatTargetTime = hcatTargetTime * 60 * 60
ctx.hcatTopMask(ctx.hcatHashType, ctx.hcatHashFile, hcatTargetTime)
@@ -202,9 +224,11 @@ def hybrid_crack(ctx: Any) -> None:
print(" - Mode 7: mask + wordlist (e.g., '123' + 'password')")
print("=" * 60)
use_default = input("\nUse default hybrid wordlist from config? (Y/n): ").strip().lower()
use_default = (
input("\nUse default hybrid wordlist from config? (Y/n): ").strip().lower()
)
if use_default != 'n':
if use_default != "n":
print("\nUsing default wordlist(s) from config:")
if isinstance(ctx.hcatHybridlist, list):
for wl in ctx.hcatHybridlist:
@@ -221,8 +245,7 @@ def hybrid_crack(ctx: Any) -> None:
print(" - Press TAB to autocomplete file paths")
selection = ctx.select_file_with_autocomplete(
"Enter wordlist file(s) (comma-separated for multiple)",
allow_multiple=True
"Enter wordlist file(s) (comma-separated for multiple)", allow_multiple=True
)
if not selection:
+3 -1
View File
@@ -21,5 +21,7 @@ def setup_logging(logger: logging.Logger, hate_path: str, debug_mode: bool) -> N
if not any(isinstance(h, logging.FileHandler) for h in logger.handlers):
log_path = os.path.join(hate_path, "hate_crack.log")
file_handler = logging.FileHandler(log_path)
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
file_handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(message)s")
)
logger.addHandler(file_handler)
+925 -623
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -25,3 +25,18 @@ hate_crack = [
"hashcat-utils/**",
"princeprocessor/**",
]
[tool.ruff]
exclude = [
"build",
"dist",
"PACK",
"hashcat-utils",
"princeprocessor",
"wordlists",
]
[tool.pytest.ini_options]
testpaths = [
"tests",
]
+21 -8
View File
@@ -1,21 +1,22 @@
import os
import sys
import types
import pytest
from unittest import mock
import hate_crack.api as api
@pytest.fixture
def fake_file(tmp_path):
return tmp_path / "testfile.txt"
@pytest.fixture
def patch_dependencies(tmp_path):
# Patch sanitize_filename to just return the filename
patch1 = mock.patch("hate_crack.api.sanitize_filename", side_effect=lambda x: x)
# Patch get_hcat_wordlists_dir to return tmp_path
patch2 = mock.patch("hate_crack.api.get_hcat_wordlists_dir", return_value=str(tmp_path))
patch2 = mock.patch(
"hate_crack.api.get_hcat_wordlists_dir", return_value=str(tmp_path)
)
# Patch extract_with_7z to just return True
patch3 = mock.patch("hate_crack.api.extract_with_7z", return_value=True)
# Patch os.replace to do nothing
@@ -29,16 +30,18 @@ def patch_dependencies(tmp_path):
for p in patches:
p.stop()
def make_mock_response(content=b"abc", total=3, status_code=200, endswith='.txt'):
def make_mock_response(content=b"abc", total=3, status_code=200, endswith=".txt"):
mock_resp = mock.MagicMock()
mock_resp.__enter__.return_value = mock_resp
mock_resp.__exit__.return_value = False
mock_resp.iter_content = lambda chunk_size: [content]
mock_resp.headers = {'content-length': str(total)}
mock_resp.headers = {"content-length": str(total)}
mock_resp.status_code = status_code
mock_resp.raise_for_status = mock.Mock()
return mock_resp
def test_download_success(tmp_path, patch_dependencies):
file_name = "wordlist.txt"
out_path = str(tmp_path / file_name)
@@ -49,6 +52,7 @@ def test_download_success(tmp_path, patch_dependencies):
assert result is True
m_open.assert_called() # File was opened for writing
def test_download_7z_triggers_extract(tmp_path, patch_dependencies):
file_name = "archive.7z"
out_path = str(tmp_path / file_name)
@@ -61,26 +65,35 @@ def test_download_7z_triggers_extract(tmp_path, patch_dependencies):
assert result is True
m_extract.assert_called_once()
def test_download_keyboard_interrupt(tmp_path, patch_dependencies):
file_name = "wordlist.txt"
out_path = str(tmp_path / file_name)
# Simulate KeyboardInterrupt in requests.get context manager
def raise_keyboard_interrupt(*a, **kw):
raise KeyboardInterrupt()
with mock.patch("hate_crack.api.requests.get", side_effect=raise_keyboard_interrupt):
with mock.patch(
"hate_crack.api.requests.get", side_effect=raise_keyboard_interrupt
):
result = api.download_official_wordlist(file_name, out_path)
assert result is False
def test_download_exception(tmp_path, patch_dependencies):
file_name = "wordlist.txt"
out_path = str(tmp_path / file_name)
# Simulate generic Exception in requests.get context manager
def raise_exception(*a, **kw):
raise Exception("fail")
with mock.patch("hate_crack.api.requests.get", side_effect=raise_exception):
result = api.download_official_wordlist(file_name, out_path)
assert result is False
def test_progress_bar_prints(tmp_path, patch_dependencies, capsys):
file_name = "wordlist.txt"
out_path = str(tmp_path / file_name)
@@ -92,4 +105,4 @@ def test_progress_bar_prints(tmp_path, patch_dependencies, capsys):
result = api.download_official_wordlist(file_name, out_path)
assert result is True
captured = capsys.readouterr()
assert "Downloaded" in captured.out or "Downloaded" in captured.err
assert "Downloaded" in captured.out or "Downloaded" in captured.err
+22 -28
View File
@@ -4,35 +4,35 @@ not from hcatPath (which should point to hashcat binary location).
This prevents regression where hcatPath was incorrectly used for utilities.
"""
import os
import json
import tempfile
import pytest
def test_hashcat_utils_uses_hate_path_not_hcat_path(tmp_path, monkeypatch):
"""
Verify that hashcat-utils is loaded from hate_crack repo, not hcatPath.
This test ensures that even when hcatPath points to a different directory
(like /opt/hashcat), the code correctly uses hate_path for utilities.
"""
# Set HATE_CRACK_SKIP_INIT to prevent initialization checks
monkeypatch.setenv("HATE_CRACK_SKIP_INIT", "1")
# Import after setting env var
from hate_crack import main
# The hate_path should be the hate_crack repository
assert main.hate_path is not None
assert os.path.isdir(main.hate_path)
assert os.path.isdir(os.path.join(main.hate_path, "hashcat-utils"))
# The hcatPath might be different (fallback to hate_path if empty)
# But utilities should ALWAYS use hate_path
assert main.hcatPath is not None
# Key assertion: even if hcatPath != hate_path,
# Key assertion: even if hcatPath != hate_path,
# the code should look for utilities in hate_path
# This is verified by checking the actual code paths used
@@ -43,11 +43,9 @@ def test_config_with_explicit_hashcat_path():
the code still finds utilities in the hate_crack repository.
"""
import os
import tempfile
import json
# Create a temporary config with hcatPath set to a different location
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
test_config = {
"hcatPath": "/opt/hashcat", # Different from hate_crack repo
"hcatBin": "hashcat",
@@ -64,42 +62,38 @@ def test_config_with_explicit_hashcat_path():
"hcatThoroughBaseList": ["rockyou.txt"],
"hcatThoroughCombList": ["rockyou.txt"],
"hashview_url": "https://localhost:8443",
"hashview_api_key": ""
"hashview_api_key": "",
}
json.dump(test_config, f)
config_path = f.name
try:
# Load the config
with open(config_path) as f:
config = json.load(f)
# Verify that hcatPath is set to /opt/hashcat
assert config['hcatPath'] == '/opt/hashcat'
assert config["hcatPath"] == "/opt/hashcat"
# This documents the expected behavior:
# - hcatPath = /opt/hashcat (for hashcat binary)
# - hashcat-utils should be found in hate_crack repo, not /opt/hashcat
finally:
os.unlink(config_path)
def test_readme_documents_correct_usage():
"""Verify README correctly explains hcatPath vs asset locations."""
readme_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
'README.md'
)
readme_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "README.md")
with open(readme_path) as f:
readme = f.read()
# Check that README mentions the correct relationship
assert 'hcatPath' in readme
assert 'repository directory' in readme
assert 'hashcat-utils' in readme
assert "hcatPath" in readme
assert "repository directory" in readme
assert "hashcat-utils" in readme
# Should NOT suggest putting hashcat-utils in hashcat directory
# (This is a documentation test to prevent confusing users)
+37 -15
View File
@@ -3,40 +3,62 @@ import sys
import os
import pytest
HATE_CRACK_SCRIPT = os.path.join(os.path.dirname(__file__), '..', 'hate_crack.py')
HATE_CRACK_SCRIPT = os.path.join(os.path.dirname(__file__), "..", "hate_crack.py")
@pytest.mark.parametrize("flag,menu_text,alt_text", [
("--hashview", "Available Customers", None),
("--weakpass", "Available Wordlists", None),
("--hashmob", "Official Hashmob Wordlists", None),
])
@pytest.mark.parametrize(
"flag,menu_text,alt_text",
[
("--hashview", "Available Customers", None),
("--weakpass", "Available Wordlists", None),
("--hashmob", "Official Hashmob Wordlists", None),
],
)
def test_direct_menu_flags(monkeypatch, flag, menu_text, alt_text):
# Only check external services if explicitly enabled
if flag == "--hashmob" and not os.environ.get('HASHMOB_TEST_REAL', '').lower() in ('1', 'true', 'yes'):
if flag == "--hashmob" and os.environ.get("HASHMOB_TEST_REAL", "").lower() not in (
"1",
"true",
"yes",
):
pytest.skip("Skipping --hashmob test unless HASHMOB_TEST_REAL is set.")
if flag == "--hashview" and not os.environ.get('HASHVIEW_TEST_REAL', '').lower() in ('1', 'true', 'yes'):
if flag == "--hashview" and os.environ.get(
"HASHVIEW_TEST_REAL", ""
).lower() not in ("1", "true", "yes"):
pytest.skip("Skipping --hashview test unless HASHVIEW_TEST_REAL is set.")
if flag == "--weakpass" and not os.environ.get('WEAKPASS_TEST_REAL', '').lower() in ('1', 'true', 'yes'):
if flag == "--weakpass" and os.environ.get(
"WEAKPASS_TEST_REAL", ""
).lower() not in ("1", "true", "yes"):
pytest.skip("Skipping --weakpass test unless WEAKPASS_TEST_REAL is set.")
cli_cmd = [sys.executable, HATE_CRACK_SCRIPT, flag]
def fake_input(prompt):
return 'q'
monkeypatch.setattr('builtins.input', fake_input)
return "q"
monkeypatch.setattr("builtins.input", fake_input)
result = subprocess.run(
cli_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env={**os.environ, 'PYTHONUNBUFFERED': '1'}
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
output = result.stdout + result.stderr
# If Hashmob is down, skip the test for --hashmob
if flag == "--hashmob" and ("523" in output or "Server Error" in output or "Error listing official wordlists" in output):
if flag == "--hashmob" and (
"523" in output
or "Server Error" in output
or "Error listing official wordlists" in output
):
pytest.skip("Hashmob is down or unreachable (error 523 or server error)")
if alt_text:
assert menu_text in output or alt_text in output, f"Expected '{menu_text}' or '{alt_text}' in output for flag {flag}"
assert menu_text in output or alt_text in output, (
f"Expected '{menu_text}' or '{alt_text}' in output for flag {flag}"
)
else:
assert menu_text in output, f"Menu text '{menu_text}' not found in output for flag {flag}"
assert menu_text in output, (
f"Menu text '{menu_text}' not found in output for flag {flag}"
)
# Accept returncode 1 for --hashview, since 'q' is not a valid customer ID and triggers an error exit
if flag == "--hashview":
assert result.returncode in (0, 1, 130)
+5 -1
View File
@@ -5,7 +5,11 @@ import pytest
def test_cli_weakpass_exits(hc_module, monkeypatch, capsys):
hc = hc_module
monkeypatch.setattr(hc, "weakpass_wordlist_menu", lambda **kwargs: print("weakpass_wordlist_menu called"))
monkeypatch.setattr(
hc,
"weakpass_wordlist_menu",
lambda **kwargs: print("weakpass_wordlist_menu called"),
)
monkeypatch.setattr(sys, "argv", ["hate_crack.py", "--weakpass"])
with pytest.raises(SystemExit) as excinfo:
hc.main()
+5 -1
View File
@@ -8,7 +8,11 @@ import pytest
def _require_executable(name):
if shutil.which(name) is None:
warnings.warn(f"Missing required dependency: {name}", RuntimeWarning)
if os.environ.get("HATE_CRACK_REQUIRE_DEPS", "").lower() in ("1", "true", "yes"):
if os.environ.get("HATE_CRACK_REQUIRE_DEPS", "").lower() in (
"1",
"true",
"yes",
):
pytest.fail(f"Required dependency not installed: {name}")
pytest.skip(f"Missing required dependency: {name}")
+10 -10
View File
@@ -34,12 +34,11 @@ def docker_image():
pytest.fail(f"Docker build timed out after {exc.timeout}s")
assert build.returncode == 0, (
"Docker build failed. "
f"stdout={build.stdout} stderr={build.stderr}"
f"Docker build failed. stdout={build.stdout} stderr={build.stderr}"
)
yield image_tag
# Cleanup: remove the Docker image after tests complete
try:
result = subprocess.run(
@@ -52,11 +51,14 @@ def docker_image():
print(
f"Warning: Failed to remove Docker image {image_tag}. "
f"stderr={result.stderr}",
file=sys.stderr
file=sys.stderr,
)
except Exception as e:
# Don't fail the test if cleanup fails, but log the issue
print(f"Warning: Exception while removing Docker image {image_tag}: {e}", file=sys.stderr)
print(
f"Warning: Exception while removing Docker image {image_tag}: {e}",
file=sys.stderr,
)
def _run_container(image_tag, command, timeout=180):
@@ -79,8 +81,7 @@ def test_docker_script_install_and_run(docker_image):
timeout=120,
)
assert run.returncode == 0, (
"Docker script install/run failed. "
f"stdout={run.stdout} stderr={run.stderr}"
f"Docker script install/run failed. stdout={run.stdout} stderr={run.stderr}"
)
@@ -94,6 +95,5 @@ def test_docker_hashcat_cracks_simple_password(docker_image):
)
run = _run_container(docker_image, command, timeout=180)
assert run.returncode == 0, (
"Docker hashcat crack failed. "
f"stdout={run.stdout} stderr={run.stderr}"
f"Docker hashcat crack failed. stdout={run.stdout} stderr={run.stderr}"
)
+2 -4
View File
@@ -38,8 +38,7 @@ def test_local_uv_tool_install_and_help(tmp_path):
text=True,
)
assert install.returncode == 0, (
"uv tool install failed. "
f"stdout={install.stdout} stderr={install.stderr}"
f"uv tool install failed. stdout={install.stdout} stderr={install.stderr}"
)
tool_help = subprocess.run(
@@ -50,8 +49,7 @@ def test_local_uv_tool_install_and_help(tmp_path):
text=True,
)
assert tool_help.returncode == 0, (
"hate_crack --help failed. "
f"stdout={tool_help.stdout} stderr={tool_help.stderr}"
f"hate_crack --help failed. stdout={tool_help.stdout} stderr={tool_help.stderr}"
)
script_help = subprocess.run(
+8 -9
View File
@@ -1,4 +1,3 @@
import os
import re
import pytest
@@ -6,30 +5,30 @@ from hate_crack.api import download_hashmob_wordlist_list
def test_hashmob_connectivity_real(capsys):
if not os.environ.get('HASHMOB_TEST_REAL', '').lower() in ('1', 'true', 'yes'):
if os.environ.get("HASHMOB_TEST_REAL", "").lower() not in ("1", "true", "yes"):
# Mocked response
result = [
{'name': 'mock_wordlist_1', 'information': 'Mock info 1'},
{'name': 'mock_wordlist_2', 'information': 'Mock info 2'}
{"name": "mock_wordlist_1", "information": "Mock info 1"},
{"name": "mock_wordlist_2", "information": "Mock info 2"},
]
print("Available Hashmob Wordlists:")
for idx, wl in enumerate(result):
print(f"{idx+1}. {wl['name']} - {wl['information']}")
print(f"{idx + 1}. {wl['name']} - {wl['information']}")
captured = capsys.readouterr()
else:
try:
result = download_hashmob_wordlist_list()
except Exception as e:
if '523' in str(e) or 'HTTP ERROR 523' in str(e):
if "523" in str(e) or "HTTP ERROR 523" in str(e):
pytest.skip("Hashmob returned HTTP ERROR 523 (Origin is unreachable)")
pytest.skip(f"Network or API unavailable: {e}")
captured = capsys.readouterr()
if 'HTTP ERROR 523' in captured.out or '523' in captured.out:
if "HTTP ERROR 523" in captured.out or "523" in captured.out:
pytest.skip("Hashmob returned HTTP ERROR 523 (Origin is unreachable)")
assert isinstance(result, list)
assert any('name' in wl for wl in result)
assert any("name" in wl for wl in result)
# Check for at least one wordlist name in output using regex
names = [wl['name'] for wl in result if 'name' in wl]
names = [wl["name"] for wl in result if "name" in wl]
found = False
for name in names:
if re.search(re.escape(name), captured.out):
+355 -140
View File
@@ -1,6 +1,7 @@
"""
Tests for Hashview integration - Mocked API calls for CI/CD
"""
import pytest
import sys
import os
@@ -11,47 +12,44 @@ 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__), '..')))
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'
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')
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')
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')
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') as mock_session_class:
api = HashviewAPI(
base_url=HASHVIEW_URL,
api_key=HASHVIEW_API_KEY
)
with patch("requests.Session") as mock_session_class:
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"""
@@ -60,14 +58,14 @@ class TestHashviewAPI:
"e19ccf75ee54e06b06a5907af13cef42", # 123456 (NTLM)
"5835048ce94ad0564e29a924a03510ef", # 12345678 (NTLM)
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
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')
f.write(hash_val + "\n")
yield hashfile_path
# Cleanup
def test_list_hashfiles_success(self, api):
@@ -79,21 +77,23 @@ class TestHashviewAPI:
assert isinstance(result, list)
# If there are no hashfiles, that's valid, but if present, check structure
if result:
assert 'name' in result[0]
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'}
])
"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'
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)."""
@@ -117,23 +117,25 @@ class TestHashviewAPI:
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')
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)
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'}
])
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)
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)."""
@@ -144,42 +146,48 @@ class TestHashviewAPI:
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')
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")
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
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")
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.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
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.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')
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):
@@ -192,7 +200,7 @@ class TestHashviewAPI:
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')
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):
@@ -202,74 +210,80 @@ class TestHashviewAPI:
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
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.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"
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')
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:
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)
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.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 / "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:
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)
assert result["size"] == len(content)
def test_download_found_hashes(self, api, tmp_path):
"""Test downloading found 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')
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"found_{customer_id}_{hashfile_id}.txt"
result = real_api.download_found_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:
result = real_api.download_found_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)
assert result["size"] == len(content)
else:
# Mock test
mock_response = Mock()
mock_response.content = b"hash1:pass1\nhash2:pass2\n"
mock_response.raise_for_status = Mock()
mock_response.headers = {'content-length': '0'}
mock_response.headers = {"content-length": "0"}
def iter_content(chunk_size=8192):
yield mock_response.content
@@ -279,30 +293,32 @@ class TestHashviewAPI:
output_file = tmp_path / "found_1_2.txt"
result = api.download_found_hashes(1, 2, output_file=str(output_file))
assert os.path.exists(result['output_file'])
with open(result['output_file'], 'rb') as f:
assert os.path.exists(result["output_file"])
with open(result["output_file"], "rb") as f:
content = f.read()
assert content == b"hash1:pass1\nhash2:pass2\n"
assert result['size'] == len(content)
assert result["size"] == len(content)
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')
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:
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)
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'}
mock_response.headers = {"content-length": "0"}
def iter_content(chunk_size=8192):
yield mock_response.content
@@ -312,97 +328,90 @@ class TestHashviewAPI:
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:
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)
assert result["size"] == len(content)
def test_create_job_workflow(self, api, test_hashfile):
"""Test creating a job in Hashview (option 2 complete workflow)"""
print("\n" + "="*60)
print("\n" + "=" * 60)
print("Testing Option 2: Create Job Workflow")
print("="*60)
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'}])
"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'
"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.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']
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
test_hashfile, customer_id, hash_type, file_format, hashfile_name
)
hashfile_id = upload_result['hashfile_id']
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
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:
if "job_id" in job_result:
print(f" ✓ Job ID: {job_result['job_id']}")
print("\n" + "="*60)
print("\n" + "=" * 60)
print("✓ Option 2 (Create Job) is READY and WORKING!")
print("="*60)
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')
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')
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(
@@ -412,7 +421,7 @@ class TestHashviewAPI:
5,
"test_hashfile_new_customer",
)
hashfile_id = upload_result.get('hashfile_id')
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(
@@ -426,8 +435,8 @@ class TestHashviewAPI:
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')
assert "job_id" in job_result
job_id = job_result.get("job_id")
try:
real_api.start_job(job_id)
except Exception:
@@ -444,31 +453,237 @@ class TestHashviewAPI:
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.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'
"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.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]
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
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
assert job_result.get("job_id") == 303
if __name__ == '__main__':
pytest.main([__file__, '-v'])
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 merges found hashes"""
# 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)
# Create existing .out file
out_file = tmp_path / "left_1_2.txt.out"
out_file.write_text("previously_cracked_hash1:password1\npreviously_cracked_hash2:password2\n")
# 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]
# Download left hashes (should auto-download and merge 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
assert os.path.exists(result["output_file"])
# Verify found file was downloaded, merged, and deleted
found_file = tmp_path / "found_1_2.txt"
assert not os.path.exists(found_file), "Found file should be deleted after merge"
assert not (other_cwd / "found_1_2.txt").exists()
# Verify merged content in .out file
if os.path.exists(str(out_file)):
with open(str(out_file), 'r') as f:
merged_content = f.read()
# Should contain both previous and new found hashes
assert "previously_cracked_hash1:password1" in merged_content
assert "found_hash1:found_password1" in merged_content or "found_password1" in merged_content
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"
result = 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):
"""Test that original hashfile path is preserved before _ensure_hashfile_in_cwd"""
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
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)
# Save current directory
orig_cwd = os.getcwd()
try:
# Change to tmp_path
os.chdir(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"
finally:
os.chdir(orig_cwd)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+44 -32
View File
@@ -1,4 +1,3 @@
import os
import sys
import pytest
@@ -12,7 +11,7 @@ class DummyHashviewAPI:
self.debug = debug
self.calls = []
def upload_cracked_hashes(self, file_path, hash_type='1000'):
def upload_cracked_hashes(self, file_path, hash_type="1000"):
self.calls.append(("upload_cracked_hashes", file_path, hash_type))
return {"msg": "Cracked hashes uploaded", "count": 2}
@@ -21,19 +20,51 @@ class DummyHashviewAPI:
return {"msg": "Wordlist uploaded", "wordlist_id": 123}
def download_left_hashes(self, customer_id, hashfile_id, output_file=None):
self.calls.append(("download_left_hashes", customer_id, hashfile_id, output_file))
return {"output_file": output_file or f"left_{customer_id}_{hashfile_id}.txt", "size": 10}
self.calls.append(
("download_left_hashes", customer_id, hashfile_id, output_file)
)
return {
"output_file": output_file or f"left_{customer_id}_{hashfile_id}.txt",
"size": 10,
}
def download_found_hashes(self, customer_id, hashfile_id, output_file=None):
self.calls.append(("download_found_hashes", customer_id, hashfile_id, output_file))
return {"output_file": output_file or f"found_{customer_id}_{hashfile_id}.txt", "size": 12}
self.calls.append(
("download_found_hashes", customer_id, hashfile_id, output_file)
)
return {
"output_file": output_file or f"found_{customer_id}_{hashfile_id}.txt",
"size": 12,
}
def upload_hashfile(self, file_path, customer_id, hash_type, file_format=5, hashfile_name=None):
self.calls.append(("upload_hashfile", file_path, customer_id, hash_type, file_format, hashfile_name))
def upload_hashfile(
self, file_path, customer_id, hash_type, file_format=5, hashfile_name=None
):
self.calls.append(
(
"upload_hashfile",
file_path,
customer_id,
hash_type,
file_format,
hashfile_name,
)
)
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", name, hashfile_id, customer_id, limit_recovered, notify_email))
def create_job(
self, name, hashfile_id, customer_id, limit_recovered=False, notify_email=True
):
self.calls.append(
(
"create_job",
name,
hashfile_id,
customer_id,
limit_recovered,
notify_email,
)
)
return {"msg": "Job created", "job_id": 789}
@@ -89,28 +120,9 @@ def test_hashview_cli_upload_wordlist(_patch_hashview, monkeypatch, tmp_path, ca
assert "Wordlist uploaded" in captured.out
def test_hashview_cli_download_found(_patch_hashview, monkeypatch, tmp_path, capsys):
out_file = tmp_path / "found_1_2.txt"
code = _run_main_with_args(
monkeypatch,
[
"hashview",
"download-found",
"--customer-id",
"1",
"--hashfile-id",
"2",
"--out",
str(out_file),
],
)
captured = capsys.readouterr()
assert code == 0
assert "Downloaded" in captured.out
assert str(out_file) in captured.out
def test_hashview_cli_upload_hashfile_job(_patch_hashview, monkeypatch, tmp_path, capsys):
def test_hashview_cli_upload_hashfile_job(
_patch_hashview, monkeypatch, tmp_path, capsys
):
hashfile = tmp_path / "hashes.txt"
hashfile.write_text("hash1\n")
code = _run_main_with_args(
@@ -51,7 +51,11 @@ def _ensure_customer_one():
customers_result = api.list_customers()
except Exception as exc:
pytest.skip(f"Unable to list customers from HASHVIEW_URL: {exc}")
customers = customers_result.get("customers", []) if isinstance(customers_result, dict) else customers_result
customers = (
customers_result.get("customers", [])
if isinstance(customers_result, dict)
else customers_result
)
if not any(int(cust.get("id", 0)) == 1 for cust in customers or []):
api.create_customer("Example Customer")
return 1
@@ -61,7 +65,14 @@ def _ensure_customer_one():
"args",
[
["hashview", "upload-cracked", "--file", "dummy.out", "--hash-type", "1000"],
["hashview", "upload-wordlist", "--file", "dummy.txt", "--name", "TestWordlist"],
[
"hashview",
"upload-wordlist",
"--file",
"dummy.txt",
"--name",
"TestWordlist",
],
["hashview", "download-left", "--customer-id", "1", "--hashfile-id", "2"],
["hashview", "download-found", "--customer-id", "1", "--hashfile-id", "2"],
[
@@ -80,7 +91,9 @@ def _ensure_customer_one():
)
def test_hashview_subcommands_require_api_key(tmp_path, args):
if _config_has_hashview_key():
pytest.skip("config.json has hashview_api_key set; skip API-key missing checks.")
pytest.skip(
"config.json has hashview_api_key set; skip API-key missing checks."
)
# Ensure any dummy files referenced exist to avoid confusion if the code path changes.
for idx, arg in enumerate(args):
@@ -116,7 +129,12 @@ def test_hashview_subcommands_live_downloads():
url, key = _get_hashview_config()
if not url or not key:
pytest.skip("Missing hashview_url/hashview_api_key in config.json or env.")
env = {**os.environ, "PYTHONUNBUFFERED": "1", "HASHVIEW_URL": url, "HASHVIEW_API_KEY": key}
env = {
**os.environ,
"PYTHONUNBUFFERED": "1",
"HASHVIEW_URL": url,
"HASHVIEW_API_KEY": key,
}
base_cmd = [sys.executable, HATE_CRACK_SCRIPT, "hashview"]
customer_id = _ensure_customer_one()
@@ -174,7 +192,12 @@ def test_hashview_subcommands_live_upload_hashfile_job(tmp_path):
url, key = _get_hashview_config()
if not url or not key:
pytest.skip("Missing hashview_url/hashview_api_key in config.json or env.")
env = {**os.environ, "PYTHONUNBUFFERED": "1", "HASHVIEW_URL": url, "HASHVIEW_API_KEY": key}
env = {
**os.environ,
"PYTHONUNBUFFERED": "1",
"HASHVIEW_URL": url,
"HASHVIEW_API_KEY": key,
}
base_cmd = [sys.executable, HATE_CRACK_SCRIPT, "hashview"]
customer_id = _ensure_customer_one()
@@ -218,6 +241,7 @@ def test_hashview_subcommands_live_upload_hashfile_job(tmp_path):
if job_id:
try:
from hate_crack.api import HashviewAPI
url, key = _get_hashview_config()
if not url or not key:
return
@@ -251,7 +275,12 @@ def test_hashview_subcommands_live_upload_hashfile_job_pwdump(tmp_path):
url, key = _get_hashview_config()
if not url or not key:
pytest.skip("Missing hashview_url/hashview_api_key in config.json or env.")
env = {**os.environ, "PYTHONUNBUFFERED": "1", "HASHVIEW_URL": url, "HASHVIEW_API_KEY": key}
env = {
**os.environ,
"PYTHONUNBUFFERED": "1",
"HASHVIEW_URL": url,
"HASHVIEW_API_KEY": key,
}
base_cmd = [sys.executable, HATE_CRACK_SCRIPT, "hashview"]
customer_id = _ensure_customer_one()
@@ -298,6 +327,7 @@ def test_hashview_subcommands_live_upload_hashfile_job_pwdump(tmp_path):
if job_id:
try:
from hate_crack.api import HashviewAPI
url, key = _get_hashview_config()
if not url or not key:
return
@@ -331,7 +361,12 @@ def test_hashview_subcommands_live_upload_hashfile_job_hashonly(tmp_path):
url, key = _get_hashview_config()
if not url or not key:
pytest.skip("Missing hashview_url/hashview_api_key in config.json or env.")
env = {**os.environ, "PYTHONUNBUFFERED": "1", "HASHVIEW_URL": url, "HASHVIEW_API_KEY": key}
env = {
**os.environ,
"PYTHONUNBUFFERED": "1",
"HASHVIEW_URL": url,
"HASHVIEW_API_KEY": key,
}
base_cmd = [sys.executable, HATE_CRACK_SCRIPT, "hashview"]
customer_id = _ensure_customer_one()
@@ -376,6 +411,7 @@ def test_hashview_subcommands_live_upload_hashfile_job_hashonly(tmp_path):
if job_id:
try:
from hate_crack.api import HashviewAPI
url, key = _get_hashview_config()
if not url or not key:
return
+20 -19
View File
@@ -2,6 +2,7 @@
Tests for hate_crack execution when installed as a uv tool.
Verifies that the tool can find assets from any working directory.
"""
import subprocess
import os
import tempfile
@@ -11,7 +12,7 @@ import pytest
@pytest.mark.skipif(
not shutil.which("hate_crack"),
reason="hate_crack not installed as a tool (run 'make install' first)"
reason="hate_crack not installed as a tool (run 'make install' first)",
)
class TestInstalledToolExecution:
"""Test suite for execution of installed hate_crack tool."""
@@ -24,7 +25,7 @@ class TestInstalledToolExecution:
cwd=home_dir,
capture_output=True,
text=True,
timeout=5
timeout=5,
)
assert result.returncode == 0
assert "usage: hate_crack" in result.stdout
@@ -37,7 +38,7 @@ class TestInstalledToolExecution:
cwd="/tmp",
capture_output=True,
text=True,
timeout=5
timeout=5,
)
assert result.returncode == 0
assert "usage: hate_crack" in result.stdout
@@ -50,7 +51,7 @@ class TestInstalledToolExecution:
cwd=tmpdir,
capture_output=True,
text=True,
timeout=5
timeout=5,
)
assert result.returncode == 0
assert "usage: hate_crack" in result.stdout
@@ -58,11 +59,7 @@ class TestInstalledToolExecution:
def test_help_from_root_directory(self):
"""Test that --help works when run from root directory."""
result = subprocess.run(
["hate_crack", "--help"],
cwd="/",
capture_output=True,
text=True,
timeout=5
["hate_crack", "--help"], cwd="/", capture_output=True, text=True, timeout=5
)
assert result.returncode == 0
assert "usage: hate_crack" in result.stdout
@@ -75,7 +72,7 @@ class TestInstalledToolExecution:
cwd=home_dir,
capture_output=True,
text=True,
timeout=5
timeout=5,
)
assert result.returncode == 0
assert "usage: hate_crack" in result.stdout
@@ -88,12 +85,14 @@ class TestInstalledToolExecution:
cwd=home_dir,
capture_output=True,
text=True,
timeout=5
timeout=5,
)
assert result.returncode == 0
# Check that there are no error-related messages
assert "Error" not in result.stderr
assert "error" not in result.stdout.lower() or "error" in "usage" # "usage" might contain substring
assert (
"error" not in result.stdout.lower() or "error" in "usage"
) # "usage" might contain substring
assert "not found" not in result.stderr.lower()
assert "No such file" not in result.stderr
@@ -106,7 +105,7 @@ class TestInstalledToolExecution:
cwd=tmpdir,
capture_output=True,
text=True,
timeout=5
timeout=5,
)
assert result.returncode == 0
# Should successfully show help without errors
@@ -119,14 +118,14 @@ class TestInstalledToolExecution:
"/tmp",
"/",
]
for directory in directories:
result = subprocess.run(
["hate_crack", "--help"],
cwd=directory,
capture_output=True,
text=True,
timeout=5
timeout=5,
)
assert result.returncode == 0, f"Failed when running from {directory}"
assert "usage: hate_crack" in result.stdout
@@ -135,20 +134,22 @@ class TestInstalledToolExecution:
"""Test that assets are correctly resolved from various working directories."""
directories = [
os.path.expanduser("~"),
os.path.expanduser("~/Desktop") if os.path.exists(os.path.expanduser("~/Desktop")) else "/tmp",
os.path.expanduser("~/Desktop")
if os.path.exists(os.path.expanduser("~/Desktop"))
else "/tmp",
"/tmp",
]
for directory in directories:
if not os.path.exists(directory):
continue
result = subprocess.run(
["hate_crack", "--help"],
cwd=directory,
capture_output=True,
text=True,
timeout=5
timeout=5,
)
# Should succeed from any directory
assert result.returncode == 0, (
+5 -5
View File
@@ -1,29 +1,29 @@
"""
Test error handling when hcatPath is misconfigured.
"""
import os
import sys
import tempfile
import json
def test_ensure_binary_error_message(monkeypatch, capsys):
"""Test that ensure_binary provides helpful error when build_dir doesn't exist."""
# Import the function
from hate_crack.main import ensure_binary
# Test with non-existent build directory
fake_binary = "/nonexistent/path/to/binary"
with tempfile.TemporaryDirectory() as tmpdir:
fake_build_dir = os.path.join(tmpdir, "hashcat-utils")
# Expect SystemExit when binary and build dir don't exist
try:
ensure_binary(fake_binary, build_dir=fake_build_dir, name="expander")
assert False, "Should have exited with error"
except SystemExit as e:
assert e.code == 1
# Check that the error message mentions the correct issue
captured = capsys.readouterr()
assert "Build directory" in captured.out or "does not exist" in captured.out
@@ -34,7 +34,7 @@ def test_ensure_binary_error_message(monkeypatch, capsys):
def test_ensure_binary_with_existing_binary():
"""Test that ensure_binary succeeds when binary exists."""
from hate_crack.main import ensure_binary
# Use a system binary that definitely exists
python_path = sys.executable
result = ensure_binary(python_path)
+3 -6
View File
@@ -13,10 +13,7 @@ def test_pipal_runs_and_parses_basewords(hc_module, tmp_path, capsys):
hc.hcatHashFile = str(tmp_path / "hashes")
out_path = tmp_path / "hashes.out"
out_path.write_text(
"hash1:password123\n"
"hash2:$HEX[70617373313233]\n"
)
out_path.write_text("hash1:password123\nhash2:$HEX[70617373313233]\n")
pipal_stub = tmp_path / "pipal_stub.py"
_write_executable(
@@ -32,7 +29,7 @@ def test_pipal_runs_and_parses_basewords(hc_module, tmp_path, capsys):
" f.write('Top 3 base words\\n')\n"
" f.write('pass123 10\\n')\n"
" f.write('letmein 5\\n')\n"
" f.write('welcome 3\\n')\n"
" f.write('welcome 3\\n')\n",
)
hc.pipalPath = str(pipal_stub)
@@ -69,7 +66,7 @@ def test_pipal_missing_out_returns_empty(hc_module, tmp_path, capsys):
" f.write('Top 3 base words\\n')\n"
" f.write('pass123 10\\n')\n"
" f.write('letmein 5\\n')\n"
" f.write('welcome 3\\n')\n"
" f.write('welcome 3\\n')\n",
)
hc.pipalPath = str(pipal_stub)
+8 -4
View File
@@ -4,14 +4,16 @@ import subprocess
import json
def test_pipal_executable_and_runs(tmp_path):
# Read pipalPath from config.json
config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json')
with open(config_path, 'r') as f:
config_path = os.path.join(os.path.dirname(__file__), "..", "config.json")
with open(config_path, "r") as f:
config = json.load(f)
pipal_path = config.get('pipalPath')
pipal_path = config.get("pipalPath")
if not pipal_path or not os.path.isfile(pipal_path):
import pytest
pytest.skip("pipalPath not configured or file missing")
if not os.access(pipal_path, os.X_OK):
@@ -37,7 +39,9 @@ def test_pipal_executable_and_runs(tmp_path):
)
if not output_file.exists():
raise AssertionError("pipal did not produce an output file; it may need to be compiled.")
raise AssertionError(
"pipal did not produce an output file; it may need to be compiled."
)
content = output_file.read_text()
assert "Top 3 base words" in content
+1 -2
View File
@@ -32,6 +32,5 @@ def test_hashcat_utils_submodule_initialized():
)
assert not _is_hashcat_utils_empty(submodule_path), (
"hashcat-utils submodule is empty. "
"Run: git submodule update --init --recursive"
"hashcat-utils submodule is empty. Run: git submodule update --init --recursive"
)
+6 -2
View File
@@ -4,7 +4,9 @@ from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
CLI_SPEC = importlib.util.spec_from_file_location("hate_crack_cli", PROJECT_ROOT / "hate_crack.py")
CLI_SPEC = importlib.util.spec_from_file_location(
"hate_crack_cli", PROJECT_ROOT / "hate_crack.py"
)
CLI_MODULE = importlib.util.module_from_spec(CLI_SPEC)
CLI_SPEC.loader.exec_module(CLI_MODULE)
@@ -39,7 +41,9 @@ MENU_OPTION_TEST_CASES = [
("option_key", "target_module", "target_attr", "expected_prefix"),
MENU_OPTION_TEST_CASES,
)
def test_main_menu_option_returns_expected(monkeypatch, option_key, target_module, target_attr, expected_prefix):
def test_main_menu_option_returns_expected(
monkeypatch, option_key, target_module, target_attr, expected_prefix
):
sentinel = f"{expected_prefix}-{option_key}"
monkeypatch.setattr(
target_module,
+9 -8
View File
@@ -5,28 +5,29 @@ from hate_crack.api import HashviewAPI
def get_hashview_config():
config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json')
with open(config_path, 'r') as f:
config_path = os.path.join(os.path.dirname(__file__), "..", "config.json")
with open(config_path, "r") as f:
config = json.load(f)
hashview_url = config.get('hashview_url')
hashview_api_key = config.get('hashview_api_key')
hashview_url = config.get("hashview_url")
hashview_api_key = config.get("hashview_api_key")
return hashview_url, hashview_api_key
RUN_LIVE = os.environ.get("HATE_CRACK_RUN_LIVE_TESTS") == "1"
@pytest.mark.skipif(
not RUN_LIVE or not get_hashview_config()[0] or not get_hashview_config()[1],
reason="Requires HATE_CRACK_RUN_LIVE_TESTS=1 and hashview_url/hashview_api_key in config.json."
reason="Requires HATE_CRACK_RUN_LIVE_TESTS=1 and hashview_url/hashview_api_key in config.json.",
)
def test_upload_cracked_hashes_from_file():
hashview_url, hashview_api_key = get_hashview_config()
api = HashviewAPI(hashview_url, hashview_api_key)
file_path = os.path.join(os.path.dirname(__file__), '..', '1.out')
file_path = os.path.join(os.path.dirname(__file__), "..", "1.out")
if not os.path.isfile(file_path):
pytest.skip("1.out not found in repo root.")
result = api.upload_cracked_hashes(file_path, hash_type='1000')
result = api.upload_cracked_hashes(file_path, hash_type="1000")
assert result is not None
assert result.get('type') != 'Error'
assert result.get("type") != "Error"
+22 -15
View File
@@ -4,22 +4,24 @@ import tempfile
import pytest
from hate_crack.api import HashviewAPI
def get_hashview_config():
config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json')
with open(config_path, 'r') as f:
config_path = os.path.join(os.path.dirname(__file__), "..", "config.json")
with open(config_path, "r") as f:
config = json.load(f)
hashview_url = config.get('hashview_url')
hashview_api_key = config.get('hashview_api_key')
hashview_url = config.get("hashview_url")
hashview_api_key = config.get("hashview_api_key")
return hashview_url, hashview_api_key
def test_upload_wordlist_api_mocked(monkeypatch):
"""Test direct API upload of a wordlist file using a mocked API call."""
hashview_url, hashview_api_key = get_hashview_config()
api = HashviewAPI(hashview_url or "http://example.com", hashview_api_key or "dummy")
# Create a temp wordlist file
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write('password1\npassword2\n')
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write("password1\npassword2\n")
wordlist_path = f.name
wordlist_name = os.path.basename(wordlist_path)
@@ -28,7 +30,12 @@ def test_upload_wordlist_api_mocked(monkeypatch):
return None
def json(self):
return {"status": 200, "type": "message", "msg": "Wordlist added", "wordlist_id": 123}
return {
"status": 200,
"type": "message",
"msg": "Wordlist added",
"wordlist_id": 123,
}
def fake_post(url, data=None, headers=None):
assert url.endswith(f"/v1/wordlists/add/{wordlist_name}")
@@ -40,9 +47,9 @@ def test_upload_wordlist_api_mocked(monkeypatch):
upload_result = api.upload_wordlist_file(wordlist_path, wordlist_name)
assert upload_result is not None
assert 'wordlist_id' in upload_result
msg = upload_result.get('msg', '').lower()
assert 'uploaded' in msg or 'added' in msg
assert "wordlist_id" in upload_result
msg = upload_result.get("msg", "").lower()
assert "uploaded" in msg or "added" in msg
os.remove(wordlist_path)
@@ -58,16 +65,16 @@ def test_upload_wordlist_api_live():
pytest.skip("Requires hashview_url and hashview_api_key in config.json.")
api = HashviewAPI(hashview_url, hashview_api_key)
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write('password1\npassword2\n')
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write("password1\npassword2\n")
wordlist_path = f.name
wordlist_name = os.path.basename(wordlist_path)
try:
upload_result = api.upload_wordlist_file(wordlist_path, wordlist_name)
assert upload_result is not None
assert 'wordlist_id' in upload_result
msg = upload_result.get('msg', '').lower()
assert 'uploaded' in msg or 'added' in msg
assert "wordlist_id" in upload_result
msg = upload_result.get("msg", "").lower()
assert "uploaded" in msg or "added" in msg
finally:
os.remove(wordlist_path)
+199
View File
@@ -0,0 +1,199 @@
import logging
import os
import importlib
from hate_crack import api
from hate_crack import cli
from hate_crack import formatting
def test_resolve_path_none_and_expand():
assert cli.resolve_path("") is None
resolved = cli.resolve_path("~")
assert resolved is not None
assert os.path.isabs(resolved)
def test_setup_logging_adds_single_filehandler(tmp_path):
logger = logging.getLogger("hate_crack_test")
logger.handlers.clear()
cli.setup_logging(logger, str(tmp_path), debug_mode=True)
cli.setup_logging(logger, str(tmp_path), debug_mode=True)
file_handlers = [h for h in logger.handlers if isinstance(h, logging.FileHandler)]
assert len(file_handlers) == 1
assert os.path.basename(file_handlers[0].baseFilename) == "hate_crack.log"
logger.handlers.clear()
def test_print_multicolumn_list_truncates(capsys, monkeypatch):
# Avoid patching os.get_terminal_size (pytest uses it internally).
monkeypatch.setattr(formatting, "_terminal_width", lambda default=120: 10)
formatting.print_multicolumn_list(
"Title",
["abcdefghijk"],
min_col_width=1,
max_col_width=10,
)
captured = capsys.readouterr()
assert "..." in captured.out
def test_print_multicolumn_list_empty_entries(capsys):
formatting.print_multicolumn_list("Empty", [])
captured = capsys.readouterr()
assert "(none)" in captured.out
def test_get_hcat_wordlists_dir_from_config(tmp_path, monkeypatch):
config_path = tmp_path / "config.json"
config_path.write_text('{"hcatWordlists": "wordlists"}')
monkeypatch.setattr(api, "_resolve_config_path", lambda: str(config_path))
result = api.get_hcat_wordlists_dir()
assert result == str(tmp_path / "wordlists")
assert os.path.isdir(result)
def test_get_hcat_wordlists_dir_fallback_cwd(tmp_path, monkeypatch):
monkeypatch.setattr(api, "_resolve_config_path", lambda: None)
monkeypatch.chdir(tmp_path)
result = api.get_hcat_wordlists_dir()
assert result == str(tmp_path / "wordlists")
assert os.path.isdir(result)
def test_get_rules_dir_from_config(tmp_path, monkeypatch):
config_path = tmp_path / "config.json"
config_path.write_text('{"rules_directory": "rules"}')
monkeypatch.setattr(api, "_resolve_config_path", lambda: str(config_path))
result = api.get_rules_dir()
assert result == str(tmp_path / "rules")
assert os.path.isdir(result)
def test_get_rules_dir_fallback_cwd(tmp_path, monkeypatch):
monkeypatch.setattr(api, "_resolve_config_path", lambda: None)
monkeypatch.chdir(tmp_path)
result = api.get_rules_dir()
assert result == str(tmp_path / "rules")
assert os.path.isdir(result)
def test_cleanup_torrent_files_removes_only_torrents(tmp_path):
torrent = tmp_path / "a.torrent"
keep = tmp_path / "b.txt"
torrent.write_text("data")
keep.write_text("data")
api.cleanup_torrent_files(directory=str(tmp_path))
assert not torrent.exists()
assert keep.exists()
def test_cleanup_torrent_files_missing_dir(capsys, tmp_path):
missing = tmp_path / "missing"
api.cleanup_torrent_files(directory=str(missing))
captured = capsys.readouterr()
assert "Failed to cleanup torrent files" in captured.out
def test_register_torrent_cleanup_idempotent(monkeypatch):
calls = []
def fake_register(fn):
calls.append(fn)
monkeypatch.setattr(api, "_TORRENT_CLEANUP_REGISTERED", False)
monkeypatch.setattr("atexit.register", fake_register)
api.register_torrent_cleanup()
api.register_torrent_cleanup()
assert len(calls) == 1
def test_line_count_and_write_helpers(tmp_path, monkeypatch):
monkeypatch.setenv("HATE_CRACK_SKIP_INIT", "1")
from hate_crack import main as main_module
importlib.reload(main_module)
input_path = tmp_path / "input.txt"
input_path.write_text("a:b:c\nno-delim\n1:2:3\n")
out_delimited = tmp_path / "out_delimited.txt"
out_unique = tmp_path / "out_unique.txt"
assert main_module.lineCount(str(input_path)) == 3
assert main_module.lineCount(str(tmp_path / "missing.txt")) == 0
assert (
main_module._write_delimited_field(str(input_path), str(out_delimited), 2)
is True
)
assert out_delimited.read_text().splitlines() == ["b", "2"]
assert (
main_module._write_delimited_field(
str(tmp_path / "missing.txt"), str(out_delimited), 2
)
is False
)
class FakePopen:
def __init__(self, args, stdin=None, stdout=None, text=None):
self.stdin = FakeStdin(self)
self._stdout = stdout
self._data = None
def wait(self):
for line in sorted(set(self._data)):
self._stdout.write(line + "\n")
return 0
class FakeStdin:
def __init__(self, popen):
self._popen = popen
self._lines = []
def write(self, data):
self._lines.append(data.rstrip("\n"))
def close(self):
self._popen._data = self._lines
monkeypatch.setattr(main_module.subprocess, "Popen", FakePopen)
assert (
main_module._write_field_sorted_unique(str(input_path), str(out_unique), 2)
is True
)
assert out_unique.read_text().splitlines() == ["2", "b"]
def test_get_customer_hashfiles_with_hashtype_filters(monkeypatch):
hv = api.HashviewAPI("https://example", "key")
monkeypatch.setattr(
hv,
"get_customer_hashfiles",
lambda customer_id: [
{"customer_id": customer_id, "hashtype": "1000"},
{"customer_id": customer_id, "hash_type": "0"},
],
)
matches = hv.get_customer_hashfiles_with_hashtype(1, target_hashtype="1000")
assert len(matches) == 1
assert matches[0]["hashtype"] == "1000"
none = hv.get_customer_hashfiles_with_hashtype(1, target_hashtype="999")
assert none == []
+3 -1
View File
@@ -14,7 +14,9 @@ def test_uv_tool_install_dryrun_metadata():
scripts = project.get("scripts", {})
assert scripts.get("hate_crack") == "hate_crack.__main__:main"
setuptools_find = data.get("tool", {}).get("setuptools", {}).get("packages", {}).get("find", {})
setuptools_find = (
data.get("tool", {}).get("setuptools", {}).get("packages", {}).get("find", {})
)
assert "hate_crack*" in setuptools_find.get("include", [])
entrypoint = repo_root / "hate_crack" / "__main__.py"