diff --git a/pyproject.toml b/pyproject.toml index b3e39cc..880ded3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,10 @@ decnet = "decnet.cli:app" [tool.pytest.ini_options] 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 = [ "ignore::pytest.PytestUnhandledThreadExceptionWarning", "ignore::DeprecationWarning", diff --git a/tests/api/auth/test_change_pass.py b/tests/api/auth/test_change_pass.py index 0940410..efda0f4 100644 --- a/tests/api/auth/test_change_pass.py +++ b/tests/api/auth/test_change_pass.py @@ -36,6 +36,7 @@ async def test_change_password(client: httpx.AsyncClient) -> None: assert resp4.status_code == 200 assert resp4.json()["must_change_password"] is False +@pytest.mark.fuzz @pytest.mark.anyio @settings(**_FUZZ_SETTINGS) @given( diff --git a/tests/api/auth/test_login.py b/tests/api/auth/test_login.py index a5a512c..1e04b78 100644 --- a/tests/api/auth/test_login.py +++ b/tests/api/auth/test_login.py @@ -34,14 +34,14 @@ async def test_login_failure(client: httpx.AsyncClient) -> None: ) assert response.status_code == 401 +@pytest.mark.anyio +@pytest.mark.fuzz @pytest.mark.anyio @settings(**_FUZZ_SETTINGS) @given( username=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: """Fuzz the login endpoint with random strings (including non-ASCII).""" _payload: dict[str, str] = {"username": username, "password": password} diff --git a/tests/api/bounty/test_get_bounties.py b/tests/api/bounty/test_get_bounties.py index 0c21c4a..92196dd 100644 --- a/tests/api/bounty/test_get_bounties.py +++ b/tests/api/bounty/test_get_bounties.py @@ -1,5 +1,7 @@ import pytest import httpx +from hypothesis import given, settings, strategies as st +from ..conftest import _FUZZ_SETTINGS @pytest.mark.anyio 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}"}) assert resp.status_code == 200 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 diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 38ee02c..51b716e 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -1,47 +1,57 @@ import os import json +import uuid as _uuid import pytest -from typing import Generator, Any, AsyncGenerator +from typing import Any, AsyncGenerator from pathlib import Path +from sqlmodel import SQLModel import httpx 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_ADMIN_PASSWORD"] = "test-password-123" from decnet.web.api import app 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 import decnet.config -TEST_STATE_FILE = Path("test-decnet-state.json") @pytest.fixture(scope="function", autouse=True) -async def setup_db(worker_id, monkeypatch) -> AsyncGenerator[None, None]: - import uuid - # Use worker-specific in-memory DB with shared cache for maximum speed - unique_id = uuid.uuid4().hex - db_path = f"file:memdb_{worker_id}_{unique_id}?mode=memory&cache=shared" - - # 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 - ) - +async def setup_db(monkeypatch) -> AsyncGenerator[None, None]: + # Unique in-memory DB per test — no file I/O, no WAL/SHM side-cars + db_url = f"sqlite+aiosqlite:///file:testdb_{_uuid.uuid4().hex}?mode=memory&cache=shared" + engine = create_async_engine(db_url, connect_args={"uri": True}, poolclass=StaticPool) + 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, "session_factory", session_factory) - - # Initialize the in-memory DB (tables + admin) - repo.reinitialize() - + + # Create schema + 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 - + await engine.dispose() @pytest.fixture @@ -55,11 +65,13 @@ async def auth_token(client: httpx.AsyncClient) -> str: return resp.json()["access_token"] @pytest.fixture(autouse=True) -def patch_state_file(monkeypatch): - monkeypatch.setattr(decnet.config, "STATE_FILE", TEST_STATE_FILE) +def patch_state_file(monkeypatch, tmp_path) -> Path: + state_file = tmp_path / "decnet-state.json" + monkeypatch.setattr(decnet.config, "STATE_FILE", state_file) + return state_file @pytest.fixture -def mock_state_file(): +def mock_state_file(patch_state_file: Path): _test_state = { "config": { "mode": "unihost", @@ -103,14 +115,15 @@ def mock_state_file(): }, "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 - if TEST_STATE_FILE.exists(): - TEST_STATE_FILE.unlink() # 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] = { - "max_examples": 50, + "max_examples": _FUZZ_EXAMPLES, "deadline": None, - "suppress_health_check": [HealthCheck.function_scoped_fixture] + "suppress_health_check": [HealthCheck.function_scoped_fixture], } diff --git a/tests/api/fleet/test_get_deckies.py b/tests/api/fleet/test_get_deckies.py index 036a9fc..93cdcb4 100644 --- a/tests/api/fleet/test_get_deckies.py +++ b/tests/api/fleet/test_get_deckies.py @@ -1,6 +1,8 @@ import pytest import httpx +from hypothesis import given, settings, strategies as st from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD +from ..conftest import _FUZZ_SETTINGS @pytest.mark.anyio 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 _data[0]["name"] == "test-decky-1" 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 diff --git a/tests/api/logs/test_get_logs.py b/tests/api/logs/test_get_logs.py index 4952b19..edca28c 100644 --- a/tests/api/logs/test_get_logs.py +++ b/tests/api/logs/test_get_logs.py @@ -22,6 +22,7 @@ async def test_get_logs_success(client: httpx.AsyncClient, auth_token: str) -> N assert data["total"] >= 0 assert isinstance(data["data"], list) +@pytest.mark.fuzz @pytest.mark.anyio @settings(**_FUZZ_SETTINGS) @given( diff --git a/tests/api/logs/test_histogram.py b/tests/api/logs/test_histogram.py index ae8a984..0c03b52 100644 --- a/tests/api/logs/test_histogram.py +++ b/tests/api/logs/test_histogram.py @@ -9,7 +9,9 @@ import json import pytest from datetime import datetime, timedelta from freezegun import freeze_time +from hypothesis import given, settings, strategies as st from decnet.web.db.sqlite.repository import SQLiteRepository +from ..conftest import _FUZZ_SETTINGS @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) total = sum(r["count"] for r in result) 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}") diff --git a/tests/api/stats/test_get_stats.py b/tests/api/stats/test_get_stats.py index de39644..0438e5c 100644 --- a/tests/api/stats/test_get_stats.py +++ b/tests/api/stats/test_get_stats.py @@ -30,6 +30,7 @@ async def test_stats_includes_deployed_count(mock_state_file, client: httpx.Asyn assert "deployed_deckies" in _data assert _data["deployed_deckies"] == 2 +@pytest.mark.fuzz @pytest.mark.anyio @settings(**_FUZZ_SETTINGS) @given( diff --git a/tests/api/test_repository.py b/tests/api/test_repository.py index 12d7bb8..3b0d31b 100644 --- a/tests/api/test_repository.py +++ b/tests/api/test_repository.py @@ -5,7 +5,9 @@ covering DEBT-006 (zero test coverage on the database layer). """ import json import pytest +from hypothesis import given, settings, strategies as st from decnet.web.db.sqlite.repository import SQLiteRepository +from .conftest import _FUZZ_SETTINGS @pytest.fixture @@ -172,3 +174,27 @@ async def test_user_lifecycle(repo): updated = await repo.get_user_by_uuid(uid) assert updated["password_hash"] == "new_hashed_pw" 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}") diff --git a/tests/api/test_schemathesis.py b/tests/api/test_schemathesis.py index 310f3da..4cab4ee 100644 --- a/tests/api/test_schemathesis.py +++ b/tests/api/test_schemathesis.py @@ -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. """ +import pytest import schemathesis from hypothesis import settings 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) +@pytest.mark.fuzz @schemathesis.pytest.parametrize(api=schema) @settings(max_examples=5, deadline=None) def test_schema_compliance(case):