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:
2026-04-09 18:32:46 -04:00
parent 6fc1a2a3ea
commit d15c106b44
11 changed files with 136 additions and 34 deletions

View File

@@ -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",

View File

@@ -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(

View File

@@ -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}

View File

@@ -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

View File

@@ -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],
} }

View File

@@ -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

View File

@@ -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(

View File

@@ -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}")

View File

@@ -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(

View File

@@ -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}")

View File

@@ -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):