Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Test Suite

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
test:
name: Python ${{ matrix.python-version }} / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
python-version: "3.11"
- os: ubuntu-latest
python-version: "3.12"
- os: windows-latest
python-version: "3.11"

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Show runtime
run: |
python --version
python -m pip --version

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements-dev.txt

- name: Check dependency consistency
run: python -m pip check

- name: Compile Python sources
run: python -m compileall -q .

- name: Run test suite with coverage
run: python -m pytest -q --cov=webapp --cov-report=term-missing --cov-report=xml:coverage.xml --junitxml=test-results.xml

- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.os }}-py${{ matrix.python-version }}
path: |
test-results.xml
coverage.xml
5 changes: 5 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Development and CI test dependencies
-r requirements.txt

pytest>=8.0
pytest-cov>=6.0
21 changes: 21 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Shared pytest fixtures for the WebScrapeHelper test suite."""

import pytest

from webapp import create_app


@pytest.fixture()
def app():
application = create_app()
application.config.update(
TESTING=True,
WTF_CSRF_ENABLED=False,
SESSION_SECRET="test-secret",
)
return application


@pytest.fixture()
def client(app):
return app.test_client()
186 changes: 186 additions & 0 deletions tests/test_api_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""Comprehensive API contract and validation tests."""

from types import SimpleNamespace

import pytest

import webapp.routes.api as api_module


class FakeService:
def __init__(self):
self.analyzer = SimpleNamespace(curve=SimpleNamespace(order=2**256 - 2**32 - 977))

def analyze_transaction(self, tx_id):
return {"success": True, "tx_id": tx_id, "weak_signatures": []}

def analyze_address(self, address, max_txs=500):
return {"success": True, "address": address, "max_txs": max_txs}

def analyze_ecdsa_pair(self, r1, s1, z1, r2, s2, z2):
return {"success": True, "r1": r1, "s1": s1, "z1": z1, "r2": r2, "s2": s2, "z2": z2}

def calculate_nonce(self, **values):
return {"success": True, "nonce": "01", "inputs": values}

def calculate_nonce_from_private_key(self, **values):
return {"success": True, "nonce": "02", "inputs": values}

def recover_with_known_nonce(self, **values):
return {"success": True, "private_key": "03", "inputs": values}

def known_addresses(self):
return ["known"]

def scan_recent_block(self):
return {"success": True, "scanned_transactions": 0}

def monitor_mempool(self):
return {"success": True, "mempool_scanned": 0}


@pytest.fixture()
def fake_service(monkeypatch):
service = FakeService()
monkeypatch.setattr(api_module, "_service", service)
return service


def test_health_contract(client):
response = client.get("/api/health")
assert response.status_code == 200
assert response.get_json() == {"status": "ok", "service": "WebScrapeHelper", "api": "v1"}


def test_all_expected_routes_are_registered(app):
routes = {rule.rule for rule in app.url_map.iter_rules()}
expected = {
"/", "/transaction", "/address", "/ecdsa-analysis",
"/api/analyze/transaction", "/api/analyze/address", "/api/analyze/ecdsa",
"/api/calculate/nonce", "/api/calculate/nonce-from-private-key",
"/api/recover/low-s-with-nonce", "/api/recover/malleability-signatures",
"/api/addresses/known", "/api/auto-scan", "/api/monitor-mempool", "/api/health",
}
assert expected <= routes


def test_transaction_requires_json_object(client):
response = client.post("/api/analyze/transaction", json=[])
assert response.status_code == 400
assert "JSON object body is required" in response.get_json()["error"]


def test_transaction_requires_tx_id(client):
response = client.post("/api/analyze/transaction", json={})
assert response.status_code == 400
assert "tx_id" in response.get_json()["error"]


def test_transaction_rejects_malformed_tx_id(client):
response = client.post("/api/analyze/transaction", json={"tx_id": "not-a-tx"})
assert response.status_code == 400
assert "Invalid transaction ID format" in response.get_json()["error"]


def test_transaction_delegates_to_service(client, fake_service):
tx_id = "00" * 32
response = client.post("/api/analyze/transaction", json={"tx_id": tx_id})
assert response.status_code == 200
assert response.get_json()["tx_id"] == tx_id


def test_address_validation_and_normalization(client, fake_service):
response = client.post(
"/api/analyze/address",
json={"address": " 1BoatSLRHtKNngkdXEeobR76b53LETtpyT ", "max_txs": 10},
)
assert response.status_code == 200
assert response.get_json()["address"] == "1BoatSLRHtKNngkdXEeobR76b53LETtpyT"
assert response.get_json()["max_txs"] == 10


@pytest.mark.parametrize("address", ["", "abc", "0x123", "1invalid0O"])
def test_address_rejects_invalid_formats(client, address):
response = client.post("/api/analyze/address", json={"address": address})
assert response.status_code == 400


def test_address_caps_max_txs(client, fake_service):
response = client.post(
"/api/analyze/address",
json={"address": "1BoatSLRHtKNngkdXEeobR76b53LETtpyT", "max_txs": 999999},
)
assert response.status_code == 200
assert response.get_json()["max_txs"] == 5000


@pytest.mark.parametrize("value", ["nope", [], {}, True, None])
def test_numeric_fields_reject_non_hex_values(client, fake_service, value):
response = client.post(
"/api/calculate/nonce",
json={"r": value, "s1": "01", "s2": "02", "z1": "03", "z2": "04"},
)
assert response.status_code == 400


def test_hex_parser_accepts_prefixed_and_unprefixed_values(client, fake_service):
response = client.post(
"/api/calculate/nonce",
json={"r": "0x01", "s1": "02", "s2": "03", "z1": "04", "z2": "05"},
)
assert response.status_code == 200
assert response.get_json()["inputs"] == {"r": 1, "s1": 2, "s2": 3, "z1": 4, "z2": 5}


def test_nonce_from_private_key_contract(client, fake_service):
response = client.post(
"/api/calculate/nonce-from-private-key",
json={"r": "01", "s": "02", "z": "03", "x": "04"},
)
assert response.status_code == 200
assert response.get_json()["success"] is True


def test_known_nonce_recovery_contract(client, fake_service):
response = client.post(
"/api/recover/low-s-with-nonce",
json={"r": "01", "s": "02", "z": "03", "k": "04"},
)
assert response.status_code == 200
assert response.get_json()["success"] is True


def test_method_not_allowed_for_post_only_endpoint(client):
response = client.get("/api/analyze/transaction")
assert response.status_code == 405
assert response.get_json()["status"] == 405


def test_unknown_api_endpoint_is_json_404(client):
response = client.get("/api/does-not-exist")
assert response.status_code == 404
assert response.is_json
assert response.get_json()["status"] == 404


def test_known_addresses_endpoint(client, fake_service):
response = client.get("/api/addresses/known")
assert response.status_code == 200
assert response.get_json() == ["known"]


def test_legacy_transaction_alias_uses_same_contract(client, fake_service):
response = client.post("/api/analyze_transaction", json={"tx_id": "00" * 32})
assert response.status_code == 200
assert response.get_json()["success"] is True


def test_unexpected_service_error_becomes_500(client, monkeypatch):
class BrokenService:
def analyze_transaction(self, tx_id):
raise RuntimeError("boom")

monkeypatch.setattr(api_module, "_service", BrokenService())
response = client.post("/api/analyze/transaction", json={"tx_id": "00" * 32})
assert response.status_code == 500
assert response.get_json() == {"error": "Internal server error", "status": 500}
46 changes: 46 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Application-factory, error-handler, and security-header tests."""


def test_security_headers_are_present(client):
response = client.get("/api/health")
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert response.headers["X-Frame-Options"] == "SAMEORIGIN"
assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"


def test_static_resources_receive_cache_header(client):
response = client.get("/static/js/api-client.js")
assert response.status_code == 200
assert "max-age=3600" in response.headers["Cache-Control"]


def test_html_404_is_not_json(client):
response = client.get("/route-that-does-not-exist")
assert response.status_code == 404
assert response.is_json is False
assert response.get_data(as_text=True) == "Not Found"


def test_api_404_is_json(client):
response = client.get("/api/route-that-does-not-exist")
assert response.status_code == 404
assert response.get_json() == {"error": "Endpoint not found", "status": 404}


def test_api_405_is_json(client):
response = client.get("/api/analyze/address")
assert response.status_code == 405
assert response.get_json() == {"error": "Method not allowed", "status": 405}


def test_html_routes_render(client):
for path in ("/", "/transaction", "/address", "/ecdsa-analysis"):
response = client.get(path)
assert response.status_code == 200, path
assert "text/html" in response.content_type


def test_app_uses_test_configuration(app):
assert app.config["TESTING"] is True
assert app.config["MAX_CONTENT_LENGTH"] > 0
assert app.config["ANALYSIS_MAX_TXS"] > 0
Loading
Loading