From 048ed7cac1df2d6c0ce1fcd113a8f08a9bc47681 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 25 Jun 2026 10:17:49 -0400 Subject: [PATCH] feat(test): run live Hashview tests against a local docker stack The live Hashview tests previously could only run against a remote server configured in config.json, and the CLI ignored env-var overrides, so the suite always hit prod. Prod runs an older Hashview whose /v1/jobs/add 500s on a notify_email kwarg, which masked real client/server drift. Wire the suite up to a local Hashview docker stack: - main.py: HASHVIEW_URL / HASHVIEW_API_KEY env vars now override config.json, so the CLI can be pointed at a local instance without editing config. - tests/_hashview_local.py + pytest_configure: when HASHVIEW_TEST_LOCAL=1, bring up + seed the Hashview compose stack (HASHVIEW_REPO), verify authenticated API access, export the HASHVIEW_* env, and tear down (HASHVIEW_KEEP=1 keeps it). Runs in configure so collection-time skipif on HASHVIEW_TEST_REAL sees the exported env. - tests/hashview_local_seed.py: seed an admin api_key + non-default password (else Hashview's setup guard redirects every request to /setup), a customer, a hashfile, and cracked "effective task" data (else /v1/jobs/add has no tasks to schedule). - api.py: download_left_hashes now uses GET /v1/hashfiles/; the old /v1/hashfiles//left route 404s on current servers. - subprocess tests: assert on the CLI's actual output ("Job ID:") rather than the literal "Job created", and strip ambient HASHVIEW_* creds from the no-key-configured check so the env override doesn't defeat it. - README/CLAUDE: document HASHVIEW_TEST_LOCAL and the env overrides. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 25 ++ hate_crack/api.py | 5 +- hate_crack/main.py | 10 +- tests/_hashview_local.py | 231 ++++++++++++++++++ tests/conftest.py | 37 +++ tests/hashview_local_seed.py | 152 ++++++++++++ tests/test_hashview.py | 10 +- ...est_hashview_cli_subcommands_subprocess.py | 27 +- 8 files changed, 488 insertions(+), 9 deletions(-) create mode 100644 tests/_hashview_local.py create mode 100644 tests/hashview_local_seed.py diff --git a/README.md b/README.md index 6f83a7b..91470ac 100644 --- a/README.md +++ b/README.md @@ -588,6 +588,31 @@ environment variable and provide valid credentials in `config.json`: HATE_CRACK_RUN_LIVE_TESTS=1 uv run pytest tests/test_upload_cracked_hashes.py -v ``` +### Live Hashview Tests Against a Local Docker Stack + +Instead of pointing the live tests at a remote Hashview server, you can have +the suite spin up a local [Hashview](https://github.com/hashview/hashview) +Docker stack, seed it, run the live tests against it, and tear it down. Set +`HASHVIEW_TEST_LOCAL=1` and point `HASHVIEW_REPO` at a Hashview checkout: + +```bash +HASHVIEW_TEST_LOCAL=1 HASHVIEW_REPO=~/projects/hashview \ + HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_hashview_cli_subcommands_subprocess.py -v +``` + +This brings up `docker compose` in the Hashview repo, seeds an admin API key, +a customer, a hashfile, and cracked "effective task" data, then exports the +`HASHVIEW_*` env vars the tests read. Useful env vars: + +- `HASHVIEW_TEST_LOCAL=1` — enable the local stack (no-op otherwise) +- `HASHVIEW_REPO=` — Hashview checkout (default `~/projects/hashview`) +- `HASHVIEW_KEEP=1` — leave containers running after the session (faster re-runs) +- `HASHVIEW_LOCAL_PORT=5000` — host port the app is published on + +The hate_crack CLI honours the `HASHVIEW_URL` / `HASHVIEW_API_KEY` environment +variables (overriding `config.json`), which is what lets the suite point the +CLI at the local stack without editing your persisted config. + ### End-to-End Install Tests (Local + Docker) Local uv tool install + script execution (uses a temporary HOME): diff --git a/hate_crack/api.py b/hate_crack/api.py index 47813c8..8e8acce 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -1344,7 +1344,10 @@ class HashviewAPI: ): import sys - url = f"{self.base_url}/v1/hashfiles/{hashfile_id}/left" + # Hashview's GET /v1/hashfiles/ streams exactly the still-uncracked + # ("left") hashes, one per line. The older /v1/hashfiles//left route + # no longer exists and 404s on current servers. + url = f"{self.base_url}/v1/hashfiles/{hashfile_id}" resp = self.session.get(url, headers=self._auth_headers(), stream=True) resp.raise_for_status() if output_file is None: diff --git a/hate_crack/main.py b/hate_crack/main.py index 93dde4a..c5fcd91 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -272,8 +272,14 @@ if _missing_keys: print(f"[config] Added {len(_missing_keys)} missing key(s) to {_config_path}") print(f" Keys: {', '.join(_missing_keys)}") -hashview_url = config_parser["hashview_url"] -hashview_api_key = config_parser["hashview_api_key"] +# Environment variables override config.json so the CLI can be pointed at a +# different Hashview instance (e.g. a local docker stack for the live test +# suite) without editing the persisted config. Empty/unset env vars fall back +# to config.json. +hashview_url = os.environ.get("HASHVIEW_URL") or config_parser["hashview_url"] +hashview_api_key = ( + os.environ.get("HASHVIEW_API_KEY") or config_parser["hashview_api_key"] +) SKIP_INIT = os.environ.get("HATE_CRACK_SKIP_INIT") == "1" diff --git a/tests/_hashview_local.py b/tests/_hashview_local.py new file mode 100644 index 0000000..c4c7555 --- /dev/null +++ b/tests/_hashview_local.py @@ -0,0 +1,231 @@ +"""Bring up / tear down a local Hashview docker stack for the live test suite. + +Used by the session-scoped ``hashview_local_stack`` fixture in ``conftest.py``. +Activated by ``HASHVIEW_TEST_LOCAL=1``; otherwise everything here is a no-op and +the live tests skip as before. + +Lifecycle (when enabled): + 1. write ``hashview/config.conf`` with ``SERVER_NAME = 127.0.0.1:5000`` so the + app is reachable from the host (a stale ``app:5000`` SERVER_NAME makes every + request 404 via Flask host matching) + 2. ``docker compose up -d --build`` + 3. poll until the app answers on the host + 4. seed the DB (admin api_key, customer, hashfile, cracked effective-task data) + 5. export ``HASHVIEW_*`` env vars so both the test helpers and the hate_crack + CLI (which now honours ``HASHVIEW_URL`` / ``HASHVIEW_API_KEY``) target local + 6. on teardown ``docker compose down -v`` unless ``HASHVIEW_KEEP=1`` + +Config via env: + HASHVIEW_TEST_LOCAL=1 enable the stack + HASHVIEW_REPO= hashview checkout (default ~/projects/hashview) + HASHVIEW_KEEP=1 leave containers running after the session + HASHVIEW_LOCAL_PORT=5000 host port the app is published on +""" +import os +import shutil +import subprocess +import time +import urllib.error +import urllib.request +from pathlib import Path + +LOCAL_API_KEY = "3eac1ab7-e525-4bb7-b565-2c9f045dfc56" +CUSTOMER_ID = "1" +HASHFILE_ID = "1" +# md5 is hashcat mode 0; the suite's pwdump test uploads NTLM (1000). Seed both. +HASH_TYPE = "0" +SEED_HASH_TYPES = "0,1000" + +_CONFIG_CONF = """[SERVER] +SERVER_NAME = 127.0.0.1:{port} +SECRET_KEY = hate-crack-local-test-secret + +[database] +host = db +username = hashview +password = hashview + +[SMTP] +server = smtp.example.com +port = 25 +use_tls = False +username = +password = +default_sender = +""" + + +def enabled() -> bool: + return os.environ.get("HASHVIEW_TEST_LOCAL", "").lower() in ("1", "true", "yes") + + +def _keep() -> bool: + return os.environ.get("HASHVIEW_KEEP", "").lower() in ("1", "true", "yes") + + +def _repo() -> Path: + return Path( + os.environ.get("HASHVIEW_REPO", os.path.expanduser("~/projects/hashview")) + ).resolve() + + +def _port() -> str: + return os.environ.get("HASHVIEW_LOCAL_PORT", "5000") + + +def _base_url() -> str: + return f"http://127.0.0.1:{_port()}" + + +def _compose(repo: Path, *args: str, check: bool = True, capture: bool = False): + env = {**os.environ, "DOCKER_PLATFORM": os.environ.get("DOCKER_PLATFORM", "linux/amd64")} + return subprocess.run( + ["docker", "compose", *args], + cwd=str(repo), + env=env, + check=check, + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.STDOUT if capture else None, + ) + + +def _http_status(url: str, cookie: str | None = None): + """Return the HTTP status for ``url`` (following no redirects), or None.""" + + class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + opener = urllib.request.build_opener(_NoRedirect) + req = urllib.request.Request(url) + if cookie: + req.add_header("Cookie", f"uuid={cookie}") + try: + with opener.open(req, timeout=3) as resp: + return resp.status + except urllib.error.HTTPError as exc: + return exc.code + except Exception: + return None + + +# Statuses that mean "Flask is up and routing" — i.e. SERVER_NAME matches and +# the app is serving. ``/login`` answers 200 or 302 (redirect) depending on +# session/setup state; either is fine. A 404 means the route didn't match +# (e.g. a stale ``app:5000`` SERVER_NAME) and a connection error (None) means +# the app isn't listening yet. +_ROUTING_STATUSES = {200, 301, 302, 303, 403} + + +def _wait_ready(timeout: float = 240.0) -> bool: + """Poll until the app is routing requests on ``/login``. + + On a cold start the DB volume is fresh, so the app boots and runs + migrations before it stops connection-refusing. Routing (not auth/seed) + readiness is the gate here; auth/seed readiness is checked separately in + :func:`_wait_authenticated`. + """ + url = f"{_base_url()}/login" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if _http_status(url) in _ROUTING_STATUSES: + return True + time.sleep(2) + return False + + +def _wait_authenticated(timeout: float = 60.0) -> bool: + """Poll an authenticated endpoint until the seeded api_key is accepted. + + Closes the race where ``/login`` is up but the seed's admin api_key isn't + effective yet: an unauthenticated ``/v1/customers`` 302-redirects (to an + HTML page), while a recognised cookie returns 200 JSON. + """ + url = f"{_base_url()}/v1/customers" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if _http_status(url, cookie=LOCAL_API_KEY) == 200: + return True + time.sleep(2) + return False + + +def _seed(repo: Path, attempts: int = 6) -> None: + seeder = Path(__file__).with_name("hashview_local_seed.py") + _compose(repo, "cp", str(seeder), "app:/tmp/hashview_local_seed.py") + seed_env = [ + "-e", "PYTHONPATH=/", + "-e", f"HASHVIEW_API_KEY={LOCAL_API_KEY}", + "-e", f"HASHVIEW_CUSTOMER_ID={CUSTOMER_ID}", + "-e", f"HASHVIEW_HASHFILE_ID={HASHFILE_ID}", + "-e", f"HASHVIEW_SEED_HASH_TYPES={SEED_HASH_TYPES}", + ] + # The admin user (id=1) and default tasks are created during the app's + # first-request boot, which can lag a few seconds behind the app routing. + # Retry so a cold start doesn't fail on "Admin user not found". + last = None + for i in range(attempts): + result = _compose( + repo, + "exec", + "-T", + *seed_env, + "-w", + "/", + "app", + "python", + "/tmp/hashview_local_seed.py", + check=False, + capture=True, + ) + if result.returncode == 0: + return + last = result.stdout + time.sleep(5) + raise RuntimeError(f"seed did not succeed after {attempts} attempts: {last}") + + +def _export_env() -> None: + os.environ["HASHVIEW_TEST_REAL"] = "1" + os.environ["HASHVIEW_URL"] = _base_url() + os.environ["HASHVIEW_API_KEY"] = LOCAL_API_KEY + os.environ["HASHVIEW_CUSTOMER_ID"] = CUSTOMER_ID + os.environ["HASHVIEW_HASHFILE_ID"] = HASHFILE_ID + os.environ.setdefault("HASHVIEW_HASH_TYPE", HASH_TYPE) + + +def setup(): + """Bring the stack up + seed. Returns a skip reason string, or None on success.""" + if shutil.which("docker") is None: + return "docker not available" + repo = _repo() + if not repo.is_dir(): + return f"hashview repo not found at {repo} (set HASHVIEW_REPO)" + if not (repo / "docker-compose.yml").is_file(): + return f"no docker-compose.yml in hashview repo {repo}" + + (repo / "hashview" / "config.conf").write_text(_CONFIG_CONF.format(port=_port())) + try: + _compose(repo, "up", "-d", "--build") + except subprocess.CalledProcessError as exc: + return f"docker compose up failed: {exc}" + if not _wait_ready(): + return f"hashview app did not become ready at {_base_url()}" + try: + _seed(repo) + except (subprocess.CalledProcessError, RuntimeError) as exc: + return f"hashview DB seed failed: {exc}" + if not _wait_authenticated(): + return "seeded api_key not accepted by Hashview (auth/seed race)" + + _export_env() + return None + + +def teardown(): + if _keep(): + return + repo = _repo() + if repo.is_dir() and (repo / "docker-compose.yml").is_file(): + _compose(repo, "down", "-v", check=False, capture=True) diff --git a/tests/conftest.py b/tests/conftest.py index 22eb2bc..2189302 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,3 +42,40 @@ def _isolate_notify_state(): notify.clear_state_for_tests() yield notify.clear_state_for_tests() + + +def pytest_configure(config): + """Spin up + seed a local Hashview docker stack for the live test suite. + + No-op unless ``HASHVIEW_TEST_LOCAL=1``. When enabled, brings up the stack + from the hashview repo, seeds the DB, and exports the ``HASHVIEW_*`` env + vars the live tests (and the hate_crack CLI) read so they target the local + instance instead of whatever ``config.json`` points at. + + This runs in ``pytest_configure`` — *before* collection — on purpose: the + live subprocess tests gate on ``HASHVIEW_TEST_REAL`` via ``@skipif``, which + is evaluated at collection time. A session fixture would set the env too + late and every live test would skip. On failure we deliberately leave + ``HASHVIEW_TEST_REAL`` unset so the live tests skip with their normal + reason rather than erroring. See ``tests/_hashview_local.py`` for config. + """ + from tests import _hashview_local as hv + + if not hv.enabled(): + return + reason = hv.setup() + if reason is not None: + config.issue_config_time_warning( + pytest.PytestWarning( + f"HASHVIEW_TEST_LOCAL set but local stack unavailable " + f"({reason}); live Hashview tests will skip." + ), + stacklevel=2, + ) + + +def pytest_unconfigure(config): + from tests import _hashview_local as hv + + if hv.enabled(): + hv.teardown() diff --git a/tests/hashview_local_seed.py b/tests/hashview_local_seed.py new file mode 100644 index 0000000..961a337 --- /dev/null +++ b/tests/hashview_local_seed.py @@ -0,0 +1,152 @@ +"""Seed a local Hashview docker stack to the state hate_crack's live tests need. + +Runs INSIDE the Hashview ``app`` container (``PYTHONPATH=/``), where the +``hashview`` package is importable. Idempotent. + +Brings the database to: +- admin user (id=1) with a known ``api_key`` (so hate_crack can authenticate) +- a Settings row (so the /setup wizard does not intercept requests) +- a Customer at ``HASHVIEW_CUSTOMER_ID`` +- a Hashfile at ``HASHVIEW_HASHFILE_ID`` holding one (uncracked) hash, so the + ``download-hashes`` / left-list endpoints return data +- at least one *cracked* Hashes row per hash type in + ``HASHVIEW_SEED_HASH_TYPES`` (comma separated, default ``0,1000``) attached to + a real Task. ``/v1/jobs/add`` derives its "top 10 effective tasks" from + cracked hashes of the uploaded file's hash type; without this it answers + "Not enough data to determine effective tasks" and job creation fails. + +Required env: HASHVIEW_API_KEY, HASHVIEW_CUSTOMER_ID, HASHVIEW_HASHFILE_ID. +Optional env: HASHVIEW_SEED_HASH_TYPES, HASHVIEW_SEED_TASK_ID (default 1). +""" +import os +import sys + +from flask import Flask +from flask_bcrypt import Bcrypt + +from hashview.config import Config +from hashview.models import ( + Customers, + Hashes, + HashfileHashes, + Hashfiles, + Settings, + Tasks, + Users, + db, +) + +REQUIRED_ENV = ( + "HASHVIEW_API_KEY", + "HASHVIEW_CUSTOMER_ID", + "HASHVIEW_HASHFILE_ID", +) + + +def build_app() -> Flask: + app = Flask(__name__) + app.config.from_object(Config) + db.init_app(app) + Bcrypt(app) + return app + + +def seed(app: Flask) -> None: + api_key = os.environ["HASHVIEW_API_KEY"] + customer_id = int(os.environ["HASHVIEW_CUSTOMER_ID"]) + hashfile_id = int(os.environ["HASHVIEW_HASHFILE_ID"]) + task_id = int(os.environ.get("HASHVIEW_SEED_TASK_ID", "1")) + hash_types = [ + int(x) for x in os.environ.get("HASHVIEW_SEED_HASH_TYPES", "0,1000").split(",") if x.strip() + ] + + bcrypt = Bcrypt(app) + + with app.app_context(): + admin = db.session.get(Users, 1) + if admin is None: + raise RuntimeError( + "Admin user (id=1) not found. App must finish booting before seeding." + ) + admin.api_key = api_key + admin.admin = True + # Hashview's setup guard (do_gui_setup_if_needed) redirects every request + # to /setup/admin-pass while the admin password is still the default. + # Set a non-default password so the API is reachable without the wizard. + admin.password = bcrypt.generate_password_hash( + os.environ.get("HASHVIEW_ADMIN_PASSWORD", "hate-crack-local-pass") + ).decode("utf-8") + admin.email_address = admin.email_address or "admin@hate-crack.local" + + if db.session.query(Settings).first() is None: + db.session.add( + Settings(retention_period=30, max_runtime_jobs=0, max_runtime_tasks=0) + ) + + if db.session.get(Customers, customer_id) is None: + db.session.add(Customers(id=customer_id, name="hate_crack Local Customer")) + + if db.session.get(Tasks, task_id) is None: + raise RuntimeError( + f"Task id={task_id} not found. Default tasks should exist after app boot." + ) + + if db.session.get(Hashfiles, hashfile_id) is None: + db.session.add( + Hashfiles( + id=hashfile_id, + name="hate-crack-local-hashfile", + customer_id=customer_id, + owner_id=admin.id, + ) + ) + db.session.flush() + hash_row = Hashes( + sub_ciphertext="d41d8cd98f00b204e9800998ecf8427e", + ciphertext="d41d8cd98f00b204e9800998ecf8427e", + hash_type=0, + cracked=False, + ) + db.session.add(hash_row) + db.session.flush() + db.session.add( + HashfileHashes( + hash_id=hash_row.id, username="local-user", hashfile_id=hashfile_id + ) + ) + + # Cracked "effective task" data, one row per hash type the tests upload. + for ht in hash_types: + sub = f"seed-cracked-{ht}" + exists = ( + db.session.query(Hashes) + .filter_by(sub_ciphertext=sub, hash_type=ht, cracked=True) + .first() + ) + if exists is None: + db.session.add( + Hashes( + sub_ciphertext=sub, + ciphertext=sub, + hash_type=ht, + cracked=True, + task_id=task_id, + plaintext="password", + ) + ) + + db.session.commit() + + +def main() -> int: + missing = [k for k in REQUIRED_ENV if not os.getenv(k)] + if missing: + print(f"hashview_local_seed: missing env vars: {missing}", file=sys.stderr) + return 2 + seed(build_app()) + print("hashview_local_seed: ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_hashview.py b/tests/test_hashview.py index b8d6905..664e70c 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -262,9 +262,15 @@ class TestHashviewAPI: assert content == b"hash1\nhash2\n" assert result["size"] == len(content) - # Verify auth headers were passed in the left hashes download call + # Verify auth headers were passed in the left hashes download call. + # The uncracked ("left") hashes come from GET /v1/hashfiles/ + # (the trailing /found call is a separate lookup). call_args_list = api.session.get.call_args_list - left_call = [c for c in call_args_list if "left" in str(c)][0] + left_call = [ + c + for c in call_args_list + if "/v1/hashfiles/2" in str(c) and "found" not 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) diff --git a/tests/test_hashview_cli_subcommands_subprocess.py b/tests/test_hashview_cli_subcommands_subprocess.py index 529e57d..01d8a43 100644 --- a/tests/test_hashview_cli_subcommands_subprocess.py +++ b/tests/test_hashview_cli_subcommands_subprocess.py @@ -105,6 +105,16 @@ def test_hashview_subcommands_require_api_key(tmp_path, args): path.write_text("dummy\n") args[idx + 1] = str(path) + # Strip any ambient Hashview credentials (e.g. exported by the local-stack + # fixture) so this exercises the genuine no-key-configured path. The CLI + # honours HASHVIEW_URL / HASHVIEW_API_KEY as overrides, so leaving them set + # would supply a key and defeat the check. + sub_env = { + k: v + for k, v in os.environ.items() + if k not in ("HASHVIEW_URL", "HASHVIEW_API_KEY") + } + sub_env["PYTHONUNBUFFERED"] = "1" cli_cmd = [sys.executable, HATE_CRACK_SCRIPT] + args result = subprocess.run( cli_cmd, @@ -112,7 +122,7 @@ def test_hashview_subcommands_require_api_key(tmp_path, args): stderr=subprocess.PIPE, text=True, cwd=REPO_ROOT, - env={**os.environ, "PYTHONUNBUFFERED": "1"}, + env=sub_env, ) output = result.stdout + result.stderr assert "Hashview API key not configured" in output @@ -215,7 +225,10 @@ def test_hashview_subcommands_live_upload_hashfile_job(tmp_path): ) assert run.returncode == 0, output assert ("Hashfile uploaded" in output) or ("Hashfile added" in output) - assert ("Job created" in output) or ("Failed to add job" in output) + # Success surfaces the server's job id ("Job ID: N"); a graceful failure + # surfaces an "Error:" line. The CLI echoes the server's own message ("Job + # added"), so don't assert on the literal "Job created". + assert ("Job ID:" in output) or ("Error:" in output) if "Job ID:" in output: job_id = None for line in output.splitlines(): @@ -305,7 +318,10 @@ def test_hashview_subcommands_live_upload_hashfile_job_pwdump(tmp_path): ) assert run.returncode == 0, output assert ("Hashfile uploaded" in output) or ("Hashfile added" in output) - assert ("Job created" in output) or ("Failed to add job" in output) + # Success surfaces the server's job id ("Job ID: N"); a graceful failure + # surfaces an "Error:" line. The CLI echoes the server's own message ("Job + # added"), so don't assert on the literal "Job created". + assert ("Job ID:" in output) or ("Error:" in output) if "Job ID:" in output: job_id = None for line in output.splitlines(): @@ -389,7 +405,10 @@ def test_hashview_subcommands_live_upload_hashfile_job_hashonly(tmp_path): output = run.stdout + run.stderr assert run.returncode == 0, output assert ("Hashfile uploaded" in output) or ("Hashfile added" in output) - assert ("Job created" in output) or ("Failed to add job" in output) + # Success surfaces the server's job id ("Job ID: N"); a graceful failure + # surfaces an "Error:" line. The CLI echoes the server's own message ("Job + # added"), so don't assert on the literal "Job created". + assert ("Job ID:" in output) or ("Error:" in output) if "Job ID:" in output: job_id = None for line in output.splitlines():