mirror of
https://github.com/laramies/theHarvester.git
synced 2026-08-17 19:35:40 +02:00
* feat: add HarvestView operator UI * Use JSONL-only HarvestView file flows * Drive HarvestView activities from the API catalog * Add result action controls * Render shared DNS resolver defaults * Clarify resolver cardinality in HarvestView * Load Tabulator from CDNjs * Fix HarvestView wiki link * Align HarvestView with execution status contract * Add HarvestView import and action controls * Harden HarvestView browser assertions * Simplify HarvestView run selection * Show truthful HarvestView execution outcomes * refactor: show canonical URL results in HarvestView * Show canonical hostname results in HarvestView * test(harvestview): remove browser error race
180 lines
6.3 KiB
Python
180 lines
6.3 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from collections import Counter
|
|
from ipaddress import ip_address
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, TextIO
|
|
from urllib.parse import urlsplit
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
if TYPE_CHECKING:
|
|
from playwright.sync_api import Page, Response, Route
|
|
|
|
CDNJS_TABULATOR_ASSETS = {
|
|
'https://cdnjs.cloudflare.com/ajax/libs/tabulator-tables/6.5.2/css/tabulator.min.css',
|
|
'https://cdnjs.cloudflare.com/ajax/libs/tabulator-tables/6.5.2/js/tabulator.min.js',
|
|
}
|
|
|
|
|
|
class HarvestViewServer:
|
|
def __init__(self, repo_root: Path, port: int, environment: dict[str, str], server_log: Path) -> None:
|
|
self.repo_root = repo_root
|
|
self.port = port
|
|
self.environment = environment
|
|
self.server_log = server_log
|
|
self.url = f'http://127.0.0.1:{port}'
|
|
self._output: TextIO | None = None
|
|
self._process: subprocess.Popen[str] | None = None
|
|
|
|
def start(self) -> None:
|
|
self.server_log.parent.mkdir(parents=True, exist_ok=True)
|
|
self._output = self.server_log.open('a', encoding='utf-8')
|
|
self._process = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
'-m',
|
|
'uvicorn',
|
|
'theHarvester.lib.api.api:app',
|
|
'--host',
|
|
'127.0.0.1',
|
|
'--port',
|
|
str(self.port),
|
|
'--log-level',
|
|
'warning',
|
|
],
|
|
cwd=self.repo_root,
|
|
env=self.environment,
|
|
stdout=self._output,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
deadline = time.monotonic() + 10
|
|
while time.monotonic() < deadline:
|
|
if self._process.poll() is not None:
|
|
self.stop()
|
|
pytest.fail(f'theHarvester test server exited during startup; see {self.server_log}')
|
|
try:
|
|
if httpx.get(f'{self.url}/openapi.json', timeout=0.25).status_code == 200:
|
|
return
|
|
except httpx.HTTPError:
|
|
time.sleep(0.05)
|
|
self.stop()
|
|
pytest.fail(f'theHarvester test server did not become ready; see {self.server_log}')
|
|
|
|
def stop(self) -> None:
|
|
if self._process is not None and self._process.poll() is None:
|
|
self._process.terminate()
|
|
try:
|
|
self._process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
self._process.kill()
|
|
self._process.wait(timeout=5)
|
|
self._process = None
|
|
if self._output is not None:
|
|
self._output.close()
|
|
self._output = None
|
|
|
|
def restart(self) -> None:
|
|
self.stop()
|
|
self.start()
|
|
|
|
|
|
class BrowserFailures:
|
|
def __init__(self, page: Page) -> None:
|
|
self.console_errors: Counter[str] = Counter()
|
|
self.local_failures: Counter[tuple[str, int, str]] = Counter()
|
|
self.external_requests: list[str] = []
|
|
self.allowed_console_errors: Counter[str] = Counter()
|
|
self.allowed_responses: Counter[tuple[str, int, str]] = Counter()
|
|
page.on('console', self._record_console_message)
|
|
page.on('response', self._record_response)
|
|
page.route('**/*', self._guard_request)
|
|
|
|
def _record_console_message(self, message) -> None:
|
|
if message.type == 'error':
|
|
self.console_errors[message.text] += 1
|
|
|
|
def _guard_request(self, route: Route) -> None:
|
|
request = route.request
|
|
parsed = urlsplit(request.url)
|
|
if request.url in CDNJS_TABULATOR_ASSETS:
|
|
route.continue_()
|
|
return
|
|
if parsed.scheme not in {'http', 'https'} or parsed.hostname == 'localhost':
|
|
route.continue_()
|
|
return
|
|
try:
|
|
is_loopback = parsed.hostname is not None and ip_address(parsed.hostname).is_loopback
|
|
except ValueError:
|
|
is_loopback = False
|
|
if is_loopback:
|
|
route.continue_()
|
|
return
|
|
self.external_requests.append(f'{request.method} {request.url}')
|
|
route.abort('blockedbyclient')
|
|
|
|
def _record_response(self, response: Response) -> None:
|
|
path = urlsplit(response.url).path
|
|
if response.status >= 500 or (path.startswith('/api/v1/') and response.status == 401):
|
|
self.local_failures[(response.request.method, response.status, path)] += 1
|
|
|
|
def allow_response(self, method: str, status_code: int, path: str) -> None:
|
|
self.allowed_responses[(method, status_code, path)] += 1
|
|
|
|
def allow_console_error(self, message: str) -> None:
|
|
self.allowed_console_errors[message] += 1
|
|
|
|
def assert_clean(self) -> None:
|
|
assert self.console_errors - self.allowed_console_errors == Counter()
|
|
assert self.local_failures == self.allowed_responses
|
|
assert self.external_requests == []
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def browser_failures(page: Page) -> BrowserFailures:
|
|
failures = BrowserFailures(page)
|
|
yield failures
|
|
failures.assert_clean()
|
|
|
|
|
|
@pytest.fixture
|
|
def harvestview_server(tmp_path: Path, unused_tcp_port: int) -> HarvestViewServer:
|
|
repo_root = Path(__file__).parents[2]
|
|
artifact_dir = repo_root / 'test-results'
|
|
artifact_dir.mkdir(exist_ok=True)
|
|
server_log = artifact_dir / f'harvestview-server-{unused_tcp_port}.log'
|
|
environment = os.environ.copy()
|
|
environment.update(
|
|
{
|
|
'THEHARVESTER_API_KEY': 'harvestview-e2e-key',
|
|
'THEHARVESTER_RUN_ARTIFACTS': str(tmp_path / 'artifacts'),
|
|
'THEHARVESTER_RUN_DB': str(tmp_path / 'runs.sqlite'),
|
|
'THEHARVESTER_RUN_WORKER': 'disabled',
|
|
'ALL_PROXY': 'http://127.0.0.1:9',
|
|
'HTTPS_PROXY': 'http://127.0.0.1:9',
|
|
'HTTP_PROXY': 'http://127.0.0.1:9',
|
|
'NO_PROXY': '127.0.0.1,localhost',
|
|
'all_proxy': 'http://127.0.0.1:9',
|
|
'https_proxy': 'http://127.0.0.1:9',
|
|
'http_proxy': 'http://127.0.0.1:9',
|
|
'no_proxy': '127.0.0.1,localhost',
|
|
}
|
|
)
|
|
server = HarvestViewServer(repo_root, unused_tcp_port, environment, server_log)
|
|
server.start()
|
|
try:
|
|
yield server
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
@pytest.fixture
|
|
def harvestview_server_url(harvestview_server: HarvestViewServer) -> str:
|
|
return harvestview_server.url
|