test: fix async fixture isolation, add fuzz marks, parallelize with xdist
- Rebuild repo.engine and repo.session_factory per-test using unique in-memory SQLite URIs — fixes KeyError: 'access_token' caused by stale session_factory pointing at production DB - Add @pytest.mark.fuzz to all Hypothesis and Schemathesis tests; default run excludes them (addopts = -m 'not fuzz') - Add missing fuzz tests to bounty, fleet, histogram, and repository - Use tmp_path for state file in patch_state_file/mock_state_file to eliminate file-path race conditions under xdist parallelism - Set default addopts: -v -q -x -n logical (26 tests in ~7s)
This commit is contained in:
@@ -43,6 +43,10 @@ decnet = "decnet.cli:app"
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
addopts = "-m 'not fuzz' -v -q -x -n logical"
|
||||||
|
markers = [
|
||||||
|
"fuzz: hypothesis-based fuzz tests (slow, run with -m fuzz or -m '' for all)",
|
||||||
|
]
|
||||||
filterwarnings = [
|
filterwarnings = [
|
||||||
"ignore::pytest.PytestUnhandledThreadExceptionWarning",
|
"ignore::pytest.PytestUnhandledThreadExceptionWarning",
|
||||||
"ignore::DeprecationWarning",
|
"ignore::DeprecationWarning",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ async def test_change_password(client: httpx.AsyncClient) -> None:
|
|||||||
assert resp4.status_code == 200
|
assert resp4.status_code == 200
|
||||||
assert resp4.json()["must_change_password"] is False
|
assert resp4.json()["must_change_password"] is False
|
||||||
|
|
||||||
|
@pytest.mark.fuzz
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@settings(**_FUZZ_SETTINGS)
|
@settings(**_FUZZ_SETTINGS)
|
||||||
@given(
|
@given(
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ async def test_login_failure(client: httpx.AsyncClient) -> None:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.fuzz
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@settings(**_FUZZ_SETTINGS)
|
@settings(**_FUZZ_SETTINGS)
|
||||||
@given(
|
@given(
|
||||||
username=st.text(min_size=0, max_size=2048),
|
username=st.text(min_size=0, max_size=2048),
|
||||||
password=st.text(min_size=0, max_size=2048)
|
password=st.text(min_size=0, max_size=2048)
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_fuzz_login(client: httpx.AsyncClient, username: str, password: str) -> None:
|
async def test_fuzz_login(client: httpx.AsyncClient, username: str, password: str) -> None:
|
||||||
"""Fuzz the login endpoint with random strings (including non-ASCII)."""
|
"""Fuzz the login endpoint with random strings (including non-ASCII)."""
|
||||||
_payload: dict[str, str] = {"username": username, "password": password}
|
_payload: dict[str, str] = {"username": username, "password": password}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import httpx
|
import httpx
|
||||||
|
from hypothesis import given, settings, strategies as st
|
||||||
|
from ..conftest import _FUZZ_SETTINGS
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_add_and_get_bounty(client: httpx.AsyncClient, auth_token: str):
|
async def test_add_and_get_bounty(client: httpx.AsyncClient, auth_token: str):
|
||||||
@@ -17,3 +19,24 @@ async def test_bounty_pagination(client: httpx.AsyncClient, auth_token: str):
|
|||||||
resp = await client.get("/api/v1/bounty?limit=1&offset=0", headers={"Authorization": f"Bearer {auth_token}"})
|
resp = await client.get("/api/v1/bounty?limit=1&offset=0", headers={"Authorization": f"Bearer {auth_token}"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["limit"] == 1
|
assert resp.json()["limit"] == 1
|
||||||
|
|
||||||
|
@pytest.mark.fuzz
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@settings(**_FUZZ_SETTINGS)
|
||||||
|
@given(
|
||||||
|
limit=st.integers(min_value=-2000, max_value=5000),
|
||||||
|
offset=st.integers(min_value=-2000, max_value=5000),
|
||||||
|
bounty_type=st.one_of(st.none(), st.text(max_size=256)),
|
||||||
|
search=st.one_of(st.none(), st.text(max_size=2048)),
|
||||||
|
)
|
||||||
|
async def test_fuzz_bounty_query(client: httpx.AsyncClient, auth_token: str, limit: int, offset: int, bounty_type, search) -> None:
|
||||||
|
params = {"limit": limit, "offset": offset}
|
||||||
|
if bounty_type is not None:
|
||||||
|
params["bounty_type"] = bounty_type
|
||||||
|
if search is not None:
|
||||||
|
params["search"] = search
|
||||||
|
try:
|
||||||
|
resp = await client.get("/api/v1/bounty", params=params, headers={"Authorization": f"Bearer {auth_token}"})
|
||||||
|
assert resp.status_code in (200, 422)
|
||||||
|
except (UnicodeEncodeError,):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,44 +1,54 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import uuid as _uuid
|
||||||
import pytest
|
import pytest
|
||||||
from typing import Generator, Any, AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from sqlmodel import SQLModel
|
||||||
import httpx
|
import httpx
|
||||||
from hypothesis import HealthCheck
|
from hypothesis import HealthCheck
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
# Ensure required env vars are set to non-bad values for tests before anything imports decnet.env
|
# Must be set before any decnet import touches decnet.env
|
||||||
os.environ["DECNET_JWT_SECRET"] = "test-secret-key-at-least-32-chars-long!!"
|
os.environ["DECNET_JWT_SECRET"] = "test-secret-key-at-least-32-chars-long!!"
|
||||||
os.environ["DECNET_ADMIN_PASSWORD"] = "test-password-123"
|
os.environ["DECNET_ADMIN_PASSWORD"] = "test-password-123"
|
||||||
|
|
||||||
from decnet.web.api import app
|
from decnet.web.api import app
|
||||||
from decnet.web.dependencies import repo
|
from decnet.web.dependencies import repo
|
||||||
from decnet.web.db.sqlite.database import get_async_engine
|
from decnet.web.db.models import User
|
||||||
|
from decnet.web.auth import get_password_hash
|
||||||
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
||||||
import decnet.config
|
import decnet.config
|
||||||
|
|
||||||
TEST_STATE_FILE = Path("test-decnet-state.json")
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function", autouse=True)
|
@pytest.fixture(scope="function", autouse=True)
|
||||||
async def setup_db(worker_id, monkeypatch) -> AsyncGenerator[None, None]:
|
async def setup_db(monkeypatch) -> AsyncGenerator[None, None]:
|
||||||
import uuid
|
# Unique in-memory DB per test — no file I/O, no WAL/SHM side-cars
|
||||||
# Use worker-specific in-memory DB with shared cache for maximum speed
|
db_url = f"sqlite+aiosqlite:///file:testdb_{_uuid.uuid4().hex}?mode=memory&cache=shared"
|
||||||
unique_id = uuid.uuid4().hex
|
engine = create_async_engine(db_url, connect_args={"uri": True}, poolclass=StaticPool)
|
||||||
db_path = f"file:memdb_{worker_id}_{unique_id}?mode=memory&cache=shared"
|
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
# Patch the global repo singleton
|
|
||||||
monkeypatch.setattr(repo, "db_path", db_path)
|
|
||||||
|
|
||||||
engine = get_async_engine(db_path)
|
|
||||||
session_factory = async_sessionmaker(
|
|
||||||
engine, class_=AsyncSession, expire_on_commit=False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# Patch BOTH — session_factory is what all queries actually use
|
||||||
monkeypatch.setattr(repo, "engine", engine)
|
monkeypatch.setattr(repo, "engine", engine)
|
||||||
monkeypatch.setattr(repo, "session_factory", session_factory)
|
monkeypatch.setattr(repo, "session_factory", session_factory)
|
||||||
|
|
||||||
# Initialize the in-memory DB (tables + admin)
|
# Create schema
|
||||||
repo.reinitialize()
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
|
# Seed admin user
|
||||||
|
async with session_factory() as session:
|
||||||
|
if not (await session.execute(select(User).where(User.username == DECNET_ADMIN_USER))).scalar_one_or_none():
|
||||||
|
session.add(User(
|
||||||
|
uuid=str(_uuid.uuid4()),
|
||||||
|
username=DECNET_ADMIN_USER,
|
||||||
|
password_hash=get_password_hash(DECNET_ADMIN_PASSWORD),
|
||||||
|
role="admin",
|
||||||
|
must_change_password=True,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -55,11 +65,13 @@ async def auth_token(client: httpx.AsyncClient) -> str:
|
|||||||
return resp.json()["access_token"]
|
return resp.json()["access_token"]
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def patch_state_file(monkeypatch):
|
def patch_state_file(monkeypatch, tmp_path) -> Path:
|
||||||
monkeypatch.setattr(decnet.config, "STATE_FILE", TEST_STATE_FILE)
|
state_file = tmp_path / "decnet-state.json"
|
||||||
|
monkeypatch.setattr(decnet.config, "STATE_FILE", state_file)
|
||||||
|
return state_file
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_state_file():
|
def mock_state_file(patch_state_file: Path):
|
||||||
_test_state = {
|
_test_state = {
|
||||||
"config": {
|
"config": {
|
||||||
"mode": "unihost",
|
"mode": "unihost",
|
||||||
@@ -103,14 +115,15 @@ def mock_state_file():
|
|||||||
},
|
},
|
||||||
"compose_path": "test-compose.yml"
|
"compose_path": "test-compose.yml"
|
||||||
}
|
}
|
||||||
TEST_STATE_FILE.write_text(json.dumps(_test_state))
|
patch_state_file.write_text(json.dumps(_test_state))
|
||||||
yield _test_state
|
yield _test_state
|
||||||
if TEST_STATE_FILE.exists():
|
|
||||||
TEST_STATE_FILE.unlink()
|
|
||||||
|
|
||||||
# Share fuzz settings across API tests
|
# Share fuzz settings across API tests
|
||||||
|
# FUZZ_EXAMPLES: keep low for dev speed; bump via HYPOTHESIS_MAX_EXAMPLES env var in CI
|
||||||
|
import os as _os
|
||||||
|
_FUZZ_EXAMPLES = int(_os.environ.get("HYPOTHESIS_MAX_EXAMPLES", "10"))
|
||||||
_FUZZ_SETTINGS: dict[str, Any] = {
|
_FUZZ_SETTINGS: dict[str, Any] = {
|
||||||
"max_examples": 50,
|
"max_examples": _FUZZ_EXAMPLES,
|
||||||
"deadline": None,
|
"deadline": None,
|
||||||
"suppress_health_check": [HealthCheck.function_scoped_fixture]
|
"suppress_health_check": [HealthCheck.function_scoped_fixture],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import httpx
|
import httpx
|
||||||
|
from hypothesis import given, settings, strategies as st
|
||||||
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
||||||
|
from ..conftest import _FUZZ_SETTINGS
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_deckies_endpoint(mock_state_file, client: httpx.AsyncClient, auth_token: str):
|
async def test_get_deckies_endpoint(mock_state_file, client: httpx.AsyncClient, auth_token: str):
|
||||||
@@ -10,3 +12,15 @@ async def test_get_deckies_endpoint(mock_state_file, client: httpx.AsyncClient,
|
|||||||
assert len(_data) == 2
|
assert len(_data) == 2
|
||||||
assert _data[0]["name"] == "test-decky-1"
|
assert _data[0]["name"] == "test-decky-1"
|
||||||
assert _data[0]["service_config"]["ssh"]["banner"] == "SSH-2.0-OpenSSH_8.9"
|
assert _data[0]["service_config"]["ssh"]["banner"] == "SSH-2.0-OpenSSH_8.9"
|
||||||
|
|
||||||
|
@pytest.mark.fuzz
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@settings(**_FUZZ_SETTINGS)
|
||||||
|
@given(token=st.text(min_size=0, max_size=4096))
|
||||||
|
async def test_fuzz_deckies_auth(client: httpx.AsyncClient, token: str) -> None:
|
||||||
|
"""Fuzz the Authorization header on the deckies endpoint."""
|
||||||
|
try:
|
||||||
|
resp = await client.get("/api/v1/deckies", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code in (200, 401, 422)
|
||||||
|
except (UnicodeEncodeError,):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ async def test_get_logs_success(client: httpx.AsyncClient, auth_token: str) -> N
|
|||||||
assert data["total"] >= 0
|
assert data["total"] >= 0
|
||||||
assert isinstance(data["data"], list)
|
assert isinstance(data["data"], list)
|
||||||
|
|
||||||
|
@pytest.mark.fuzz
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@settings(**_FUZZ_SETTINGS)
|
@settings(**_FUZZ_SETTINGS)
|
||||||
@given(
|
@given(
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import json
|
|||||||
import pytest
|
import pytest
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
|
from hypothesis import given, settings, strategies as st
|
||||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||||
|
from ..conftest import _FUZZ_SETTINGS
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -98,3 +100,18 @@ async def test_histogram_search_filter(repo):
|
|||||||
result = await repo.get_log_histogram(search="service:ssh", interval_minutes=15)
|
result = await repo.get_log_histogram(search="service:ssh", interval_minutes=15)
|
||||||
total = sum(r["count"] for r in result)
|
total = sum(r["count"] for r in result)
|
||||||
assert total == 1
|
assert total == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fuzz
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@settings(**_FUZZ_SETTINGS)
|
||||||
|
@given(
|
||||||
|
search=st.one_of(st.none(), st.text(max_size=512)),
|
||||||
|
interval_minutes=st.integers(min_value=1, max_value=10000),
|
||||||
|
)
|
||||||
|
async def test_fuzz_histogram(repo, search: str | None, interval_minutes: int) -> None:
|
||||||
|
"""Fuzz histogram params — must never raise uncaught exceptions."""
|
||||||
|
try:
|
||||||
|
await repo.get_log_histogram(search=search, interval_minutes=interval_minutes)
|
||||||
|
except Exception as exc:
|
||||||
|
pytest.fail(f"get_log_histogram raised unexpectedly: {exc}")
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ async def test_stats_includes_deployed_count(mock_state_file, client: httpx.Asyn
|
|||||||
assert "deployed_deckies" in _data
|
assert "deployed_deckies" in _data
|
||||||
assert _data["deployed_deckies"] == 2
|
assert _data["deployed_deckies"] == 2
|
||||||
|
|
||||||
|
@pytest.mark.fuzz
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@settings(**_FUZZ_SETTINGS)
|
@settings(**_FUZZ_SETTINGS)
|
||||||
@given(
|
@given(
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ covering DEBT-006 (zero test coverage on the database layer).
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
|
from hypothesis import given, settings, strategies as st
|
||||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||||
|
from .conftest import _FUZZ_SETTINGS
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -172,3 +174,27 @@ async def test_user_lifecycle(repo):
|
|||||||
updated = await repo.get_user_by_uuid(uid)
|
updated = await repo.get_user_by_uuid(uid)
|
||||||
assert updated["password_hash"] == "new_hashed_pw"
|
assert updated["password_hash"] == "new_hashed_pw"
|
||||||
assert updated["must_change_password"] == 0
|
assert updated["must_change_password"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fuzz
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@settings(**_FUZZ_SETTINGS)
|
||||||
|
@given(
|
||||||
|
raw_line=st.text(max_size=2048),
|
||||||
|
fields=st.text(max_size=2048),
|
||||||
|
attacker_ip=st.text(max_size=128),
|
||||||
|
)
|
||||||
|
async def test_fuzz_add_log(repo, raw_line: str, fields: str, attacker_ip: str) -> None:
|
||||||
|
"""Fuzz add_log with arbitrary strings — must never raise uncaught exceptions."""
|
||||||
|
try:
|
||||||
|
await repo.add_log({
|
||||||
|
"decky": "fuzz-decky",
|
||||||
|
"service": "ssh",
|
||||||
|
"event_type": "connect",
|
||||||
|
"attacker_ip": attacker_ip,
|
||||||
|
"raw_line": raw_line,
|
||||||
|
"fields": fields,
|
||||||
|
"msg": "",
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
pytest.fail(f"add_log raised unexpectedly: {exc}")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ replace the checks list with the default (remove the argument) for full complian
|
|||||||
|
|
||||||
Requires DECNET_DEVELOPER=true (set in tests/conftest.py) to expose /openapi.json.
|
Requires DECNET_DEVELOPER=true (set in tests/conftest.py) to expose /openapi.json.
|
||||||
"""
|
"""
|
||||||
|
import pytest
|
||||||
import schemathesis
|
import schemathesis
|
||||||
from hypothesis import settings
|
from hypothesis import settings
|
||||||
from schemathesis.checks import not_a_server_error
|
from schemathesis.checks import not_a_server_error
|
||||||
@@ -18,6 +19,7 @@ from decnet.web.api import app
|
|||||||
schema = schemathesis.openapi.from_asgi("/openapi.json", app)
|
schema = schemathesis.openapi.from_asgi("/openapi.json", app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fuzz
|
||||||
@schemathesis.pytest.parametrize(api=schema)
|
@schemathesis.pytest.parametrize(api=schema)
|
||||||
@settings(max_examples=5, deadline=None)
|
@settings(max_examples=5, deadline=None)
|
||||||
def test_schema_compliance(case):
|
def test_schema_compliance(case):
|
||||||
|
|||||||
Reference in New Issue
Block a user