verify_password / get_password_hash are CPU-bound and take ~250ms each at rounds=12. Called directly from async endpoints, they stall every other coroutine for that window — the single biggest single-worker bottleneck on the login path. Adds averify_password / ahash_password that wrap the sync versions in asyncio.to_thread. Sync versions stay put because _ensure_admin_user and tests still use them. 5 call sites updated: login, change-password, create-user, reset-password. tests/test_auth_async.py asserts parallel averify runs concurrently (~1x of a single verify, not 2x).
169 lines
4.5 KiB
Python
169 lines
4.5 KiB
Python
"""
|
|
Schemathesis contract tests — full compliance, all checks enabled.
|
|
|
|
Requires DECNET_DEVELOPER=true (set in tests/conftest.py) to expose /openapi.json.
|
|
"""
|
|
import pytest
|
|
import schemathesis as st
|
|
from schemathesis.checks import not_a_server_error
|
|
from schemathesis.specs.openapi.checks import (
|
|
status_code_conformance,
|
|
content_type_conformance,
|
|
response_headers_conformance,
|
|
response_schema_conformance,
|
|
positive_data_acceptance,
|
|
negative_data_rejection,
|
|
missing_required_header,
|
|
unsupported_method,
|
|
use_after_free,
|
|
ensure_resource_availability,
|
|
ignored_auth,
|
|
)
|
|
from hypothesis import settings, Verbosity, HealthCheck
|
|
from decnet.web.auth import create_access_token
|
|
|
|
import subprocess
|
|
import socket
|
|
import sys
|
|
import atexit
|
|
import os
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
LIVE_PORT = _free_port()
|
|
LIVE_SERVER_URL = f"http://127.0.0.1:{LIVE_PORT}"
|
|
TEST_SECRET = "test-secret-for-automated-fuzzing"
|
|
|
|
import decnet.web.auth
|
|
decnet.web.auth.SECRET_KEY = TEST_SECRET
|
|
|
|
TEST_TOKEN = create_access_token({"uuid": "00000000-0000-0000-0000-000000000001"})
|
|
|
|
ALL_CHECKS = (
|
|
not_a_server_error,
|
|
status_code_conformance,
|
|
content_type_conformance,
|
|
response_headers_conformance,
|
|
response_schema_conformance,
|
|
positive_data_acceptance,
|
|
negative_data_rejection,
|
|
missing_required_header,
|
|
unsupported_method,
|
|
use_after_free,
|
|
ensure_resource_availability,
|
|
)
|
|
|
|
AUTH_CHECKS = (
|
|
not_a_server_error,
|
|
ignored_auth,
|
|
)
|
|
|
|
|
|
@st.hook
|
|
def before_call(context, case, *args):
|
|
case.headers = case.headers or {}
|
|
case.headers["Authorization"] = f"Bearer {TEST_TOKEN}"
|
|
if case.path and case.path.endswith("/stream"):
|
|
case.query = case.query or {}
|
|
case.query["maxOutput"] = 0
|
|
|
|
|
|
def wait_for_port(port: int, timeout: float = 10.0) -> bool:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
if sock.connect_ex(("127.0.0.1", port)) == 0:
|
|
return True
|
|
time.sleep(0.2)
|
|
return False
|
|
|
|
|
|
def start_automated_server() -> subprocess.Popen:
|
|
uvicorn_bin = "uvicorn" if os.name != "nt" else "uvicorn.exe"
|
|
uvicorn_path = str(Path(sys.executable).parent / uvicorn_bin)
|
|
|
|
env = os.environ.copy()
|
|
env["DECNET_DEVELOPER"] = "true"
|
|
env["DECNET_CONTRACT_TEST"] = "true"
|
|
env["DECNET_JWT_SECRET"] = TEST_SECRET
|
|
|
|
log_dir = Path(__file__).parent.parent.parent / "logs"
|
|
log_dir.mkdir(exist_ok=True)
|
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
log_file = open(log_dir / f"fuzz_server_{LIVE_PORT}_{ts}.log", "w")
|
|
|
|
proc = subprocess.Popen(
|
|
[
|
|
uvicorn_path,
|
|
"decnet.web.api:app",
|
|
"--host", "127.0.0.1",
|
|
"--port", str(LIVE_PORT),
|
|
"--log-level", "info",
|
|
],
|
|
env=env,
|
|
stdout=log_file,
|
|
stderr=log_file,
|
|
)
|
|
|
|
atexit.register(proc.terminate)
|
|
atexit.register(log_file.close)
|
|
|
|
if not wait_for_port(LIVE_PORT):
|
|
proc.terminate()
|
|
raise RuntimeError(f"Automated server failed to start on port {LIVE_PORT}")
|
|
|
|
return proc
|
|
|
|
|
|
_server_proc = start_automated_server()
|
|
|
|
schema = st.openapi.from_url(f"{LIVE_SERVER_URL}/openapi.json")
|
|
|
|
|
|
@pytest.mark.fuzz
|
|
@st.pytest.parametrize(api=schema)
|
|
@settings(
|
|
max_examples=3000,
|
|
deadline=None,
|
|
verbosity=Verbosity.debug,
|
|
suppress_health_check=[
|
|
HealthCheck.filter_too_much,
|
|
HealthCheck.too_slow,
|
|
HealthCheck.data_too_large,
|
|
],
|
|
)
|
|
def test_schema_compliance(case):
|
|
"""Full contract test: valid + invalid inputs, all response checks."""
|
|
case.call_and_validate(checks=ALL_CHECKS)
|
|
|
|
|
|
@pytest.mark.fuzz
|
|
@st.pytest.parametrize(api=schema)
|
|
@settings(
|
|
max_examples=500,
|
|
deadline=None,
|
|
verbosity=Verbosity.normal,
|
|
suppress_health_check=[
|
|
HealthCheck.filter_too_much,
|
|
HealthCheck.too_slow,
|
|
],
|
|
)
|
|
def test_auth_enforcement(case):
|
|
"""Verify every protected endpoint rejects requests with no token."""
|
|
case.headers = {
|
|
k: v for k, v in (case.headers or {}).items()
|
|
if k.lower() != "authorization"
|
|
}
|
|
if case.path and case.path.endswith("/stream"):
|
|
case.query = case.query or {}
|
|
case.query["maxOutput"] = 0
|
|
case.call_and_validate(checks=AUTH_CHECKS)
|