fix/merge-testing-to-main #4
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user