test: refactor suite to use AsyncClient, in-memory DBs, and parallel coverage
This commit is contained in:
@@ -20,6 +20,7 @@ dependencies = [
|
|||||||
"bcrypt>=4.1.0",
|
"bcrypt>=4.1.0",
|
||||||
"psutil>=5.9.0",
|
"psutil>=5.9.0",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
|
"sqlmodel>=0.0.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -34,6 +35,7 @@ dev = [
|
|||||||
"pytest-asyncio>=1.0",
|
"pytest-asyncio>=1.0",
|
||||||
"freezegun>=1.5",
|
"freezegun>=1.5",
|
||||||
"schemathesis>=4.0",
|
"schemathesis>=4.0",
|
||||||
|
"pytest-xdist>=3.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -49,6 +51,7 @@ filterwarnings = [
|
|||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["decnet"]
|
source = ["decnet"]
|
||||||
omit = ["*/tests/*", "templates/*"]
|
omit = ["*/tests/*", "templates/*"]
|
||||||
|
parallel = true
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
show_missing = true
|
show_missing = true
|
||||||
|
|||||||
@@ -1,61 +1,60 @@
|
|||||||
import json
|
import json
|
||||||
from fastapi.testclient import TestClient
|
import pytest
|
||||||
from decnet.web.api import app
|
|
||||||
from hypothesis import given, strategies as st, settings
|
from hypothesis import given, strategies as st, settings
|
||||||
import httpx
|
import httpx
|
||||||
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
|
from ..conftest import _FUZZ_SETTINGS
|
||||||
|
|
||||||
def test_change_password() -> None:
|
@pytest.mark.anyio
|
||||||
with TestClient(app) as client:
|
async def test_change_password(client: httpx.AsyncClient) -> None:
|
||||||
# First login to get token
|
# First login to get token
|
||||||
login_resp = client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
login_resp = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
||||||
token = login_resp.json()["access_token"]
|
token = login_resp.json()["access_token"]
|
||||||
|
|
||||||
# Try changing password with wrong old password
|
# Try changing password with wrong old password
|
||||||
resp1 = client.post(
|
resp1 = await client.post(
|
||||||
"/api/v1/auth/change-password",
|
"/api/v1/auth/change-password",
|
||||||
json={"old_password": "wrong", "new_password": "new_secure_password"},
|
json={"old_password": "wrong", "new_password": "new_secure_password"},
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
headers={"Authorization": f"Bearer {token}"}
|
||||||
)
|
)
|
||||||
assert resp1.status_code == 401
|
assert resp1.status_code == 401
|
||||||
|
|
||||||
# Change password successfully
|
# Change password successfully
|
||||||
resp2 = client.post(
|
resp2 = await client.post(
|
||||||
"/api/v1/auth/change-password",
|
"/api/v1/auth/change-password",
|
||||||
json={"old_password": DECNET_ADMIN_PASSWORD, "new_password": "new_secure_password"},
|
json={"old_password": DECNET_ADMIN_PASSWORD, "new_password": "new_secure_password"},
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
headers={"Authorization": f"Bearer {token}"}
|
||||||
)
|
)
|
||||||
assert resp2.status_code == 200
|
assert resp2.status_code == 200
|
||||||
|
|
||||||
# Verify old password no longer works
|
# Verify old password no longer works
|
||||||
resp3 = client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
resp3 = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
||||||
assert resp3.status_code == 401
|
assert resp3.status_code == 401
|
||||||
|
|
||||||
# Verify new password works and must_change_password is False
|
# Verify new password works and must_change_password is False
|
||||||
resp4 = client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": "new_secure_password"})
|
resp4 = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": "new_secure_password"})
|
||||||
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.anyio
|
||||||
@settings(**_FUZZ_SETTINGS)
|
@settings(**_FUZZ_SETTINGS)
|
||||||
@given(
|
@given(
|
||||||
old_password=st.text(min_size=0, max_size=2048),
|
old_password=st.text(min_size=0, max_size=2048),
|
||||||
new_password=st.text(min_size=0, max_size=2048)
|
new_password=st.text(min_size=0, max_size=2048)
|
||||||
)
|
)
|
||||||
def test_fuzz_change_password(old_password: str, new_password: str) -> None:
|
async def test_fuzz_change_password(client: httpx.AsyncClient, old_password: str, new_password: str) -> None:
|
||||||
"""Fuzz the change-password endpoint with random strings."""
|
"""Fuzz the change-password endpoint with random strings."""
|
||||||
with TestClient(app) as _client:
|
# Get valid token first
|
||||||
# Get valid token first
|
_login_resp: httpx.Response = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
||||||
_login_resp: httpx.Response = _client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
_token: str = _login_resp.json()["access_token"]
|
||||||
_token: str = _login_resp.json()["access_token"]
|
|
||||||
|
_payload: dict[str, str] = {"old_password": old_password, "new_password": new_password}
|
||||||
_payload: dict[str, str] = {"old_password": old_password, "new_password": new_password}
|
try:
|
||||||
try:
|
_response: httpx.Response = await client.post(
|
||||||
_response: httpx.Response = _client.post(
|
"/api/v1/auth/change-password",
|
||||||
"/api/v1/auth/change-password",
|
json=_payload,
|
||||||
json=_payload,
|
headers={"Authorization": f"Bearer {_token}"}
|
||||||
headers={"Authorization": f"Bearer {_token}"}
|
)
|
||||||
)
|
assert _response.status_code in (200, 401, 422)
|
||||||
assert _response.status_code in (200, 401, 422)
|
except (UnicodeEncodeError, json.JSONDecodeError):
|
||||||
except (UnicodeEncodeError, json.JSONDecodeError):
|
pass
|
||||||
pass
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from decnet.web.api import app
|
from decnet.web.api import app
|
||||||
from hypothesis import given, strategies as st, settings
|
from hypothesis import given, strategies as st, settings
|
||||||
@@ -6,44 +7,46 @@ import httpx
|
|||||||
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
|
from ..conftest import _FUZZ_SETTINGS
|
||||||
|
|
||||||
def test_login_success() -> None:
|
@pytest.mark.anyio
|
||||||
with TestClient(app) as client:
|
async def test_login_success(client: httpx.AsyncClient) -> None:
|
||||||
response = client.post(
|
response = await client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD}
|
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD}
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert "access_token" in data
|
assert "access_token" in data
|
||||||
assert data["token_type"] == "bearer"
|
assert data["token_type"] == "bearer"
|
||||||
assert "must_change_password" in data
|
assert "must_change_password" in data
|
||||||
assert data["must_change_password"] is True
|
assert data["must_change_password"] is True
|
||||||
|
|
||||||
def test_login_failure() -> None:
|
@pytest.mark.anyio
|
||||||
with TestClient(app) as client:
|
async def test_login_failure(client: httpx.AsyncClient) -> None:
|
||||||
response = client.post(
|
response = await client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
json={"username": DECNET_ADMIN_USER, "password": "wrongpassword"}
|
json={"username": DECNET_ADMIN_USER, "password": "wrongpassword"}
|
||||||
)
|
)
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1/auth/login",
|
|
||||||
json={"username": "nonexistent", "password": "wrongpassword"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "nonexistent", "password": "wrongpassword"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@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)
|
||||||
)
|
)
|
||||||
def test_fuzz_login(username: str, password: str) -> None:
|
|
||||||
|
@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)."""
|
"""Fuzz the login endpoint with random strings (including non-ASCII)."""
|
||||||
with TestClient(app) as _client:
|
_payload: dict[str, str] = {"username": username, "password": password}
|
||||||
_payload: dict[str, str] = {"username": username, "password": password}
|
try:
|
||||||
try:
|
_response: httpx.Response = await client.post("/api/v1/auth/login", json=_payload)
|
||||||
_response: httpx.Response = _client.post("/api/v1/auth/login", json=_payload)
|
assert _response.status_code in (200, 401, 422)
|
||||||
assert _response.status_code in (200, 401, 422)
|
except (UnicodeEncodeError, json.JSONDecodeError):
|
||||||
except (UnicodeEncodeError, json.JSONDecodeError):
|
pass
|
||||||
pass
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
from fastapi.testclient import TestClient
|
import pytest
|
||||||
from decnet.web.api import app
|
import httpx
|
||||||
|
|
||||||
def test_add_and_get_bounty(auth_token):
|
@pytest.mark.anyio
|
||||||
with TestClient(app) as client:
|
async def test_add_and_get_bounty(client: httpx.AsyncClient, auth_token: str):
|
||||||
# We can't directly call add_bounty from API yet (it's internal to ingester)
|
# We can't directly call add_bounty from API yet (it's internal to ingester)
|
||||||
# But we can test the endpoint returns 200 even if empty.
|
# But we can test the endpoint returns 200 even if empty.
|
||||||
resp = client.get("/api/v1/bounty", headers={"Authorization": f"Bearer {auth_token}"})
|
resp = await client.get("/api/v1/bounty", headers={"Authorization": f"Bearer {auth_token}"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "total" in data
|
assert "total" in data
|
||||||
assert "data" in data
|
assert "data" in data
|
||||||
assert isinstance(data["data"], list)
|
assert isinstance(data["data"], list)
|
||||||
|
|
||||||
def test_bounty_pagination(auth_token):
|
@pytest.mark.anyio
|
||||||
with TestClient(app) as client:
|
async def test_bounty_pagination(client: httpx.AsyncClient, auth_token: str):
|
||||||
resp = 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
|
||||||
|
|||||||
@@ -1,41 +1,58 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
from typing import Generator, Any
|
from typing import Generator, Any, AsyncGenerator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi.testclient import TestClient
|
import httpx
|
||||||
from hypothesis import HealthCheck
|
from hypothesis import HealthCheck
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
# Ensure required env vars are set to non-bad values for tests before anything imports 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.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.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")
|
TEST_STATE_FILE = Path("test-decnet-state.json")
|
||||||
|
|
||||||
@pytest.fixture(scope="function", autouse=True)
|
@pytest.fixture(scope="function", autouse=True)
|
||||||
def setup_db() -> Generator[None, None, None]:
|
async def setup_db(worker_id, monkeypatch) -> AsyncGenerator[None, None]:
|
||||||
# Use a unique DB for each test process/thread if possible, but for now just one
|
import uuid
|
||||||
repo.db_path = "test_api_decnet.db"
|
# Use worker-specific in-memory DB with shared cache for maximum speed
|
||||||
if os.path.exists(repo.db_path):
|
unique_id = uuid.uuid4().hex
|
||||||
try:
|
db_path = f"file:memdb_{worker_id}_{unique_id}?mode=memory&cache=shared"
|
||||||
os.remove(repo.db_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(repo, "engine", engine)
|
||||||
|
monkeypatch.setattr(repo, "session_factory", session_factory)
|
||||||
|
|
||||||
|
# Initialize the in-memory DB (tables + admin)
|
||||||
repo.reinitialize()
|
repo.reinitialize()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
if os.path.exists(repo.db_path):
|
|
||||||
try:
|
await engine.dispose()
|
||||||
os.remove(repo.db_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def auth_token() -> str:
|
async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
||||||
with TestClient(app) as client:
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as ac:
|
||||||
resp = client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
yield ac
|
||||||
return resp.json()["access_token"]
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def auth_token(client: httpx.AsyncClient) -> str:
|
||||||
|
resp = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
||||||
|
return resp.json()["access_token"]
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def patch_state_file(monkeypatch):
|
def patch_state_file(monkeypatch):
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
from fastapi.testclient import TestClient
|
import pytest
|
||||||
from decnet.web.api import app
|
import httpx
|
||||||
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
||||||
|
|
||||||
def test_get_deckies_endpoint(mock_state_file):
|
@pytest.mark.anyio
|
||||||
with TestClient(app) as _client:
|
async def test_get_deckies_endpoint(mock_state_file, client: httpx.AsyncClient, auth_token: str):
|
||||||
# Login to get token
|
_response = await client.get("/api/v1/deckies", headers={"Authorization": f"Bearer {auth_token}"})
|
||||||
_login_resp = _client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
assert _response.status_code == 200
|
||||||
_token = _login_resp.json()["access_token"]
|
_data = _response.json()
|
||||||
|
assert len(_data) == 2
|
||||||
_response = _client.get("/api/v1/deckies", headers={"Authorization": f"Bearer {_token}"})
|
assert _data[0]["name"] == "test-decky-1"
|
||||||
assert _response.status_code == 200
|
assert _data[0]["service_config"]["ssh"]["banner"] == "SSH-2.0-OpenSSH_8.9"
|
||||||
_data = _response.json()
|
|
||||||
assert len(_data) == 2
|
|
||||||
assert _data[0]["name"] == "test-decky-1"
|
|
||||||
assert _data[0]["service_config"]["ssh"]["banner"] == "SSH-2.0-OpenSSH_8.9"
|
|
||||||
|
|||||||
@@ -1,53 +1,43 @@
|
|||||||
from typing import Any, Optional
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from decnet.web.api import app
|
|
||||||
from hypothesis import given, strategies as st, settings
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from typing import Any, Optional
|
||||||
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
|
from ..conftest import _FUZZ_SETTINGS
|
||||||
|
from hypothesis import given, strategies as st, settings
|
||||||
|
|
||||||
def test_get_logs_unauthorized() -> None:
|
@pytest.mark.anyio
|
||||||
with TestClient(app) as client:
|
async def test_get_logs_unauthorized(client: httpx.AsyncClient) -> None:
|
||||||
response = client.get("/api/v1/logs")
|
response = await client.get("/api/v1/logs")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_get_logs_success() -> None:
|
@pytest.mark.anyio
|
||||||
with TestClient(app) as client:
|
async def test_get_logs_success(client: httpx.AsyncClient, auth_token: str) -> None:
|
||||||
login_response = client.post(
|
response = await client.get(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/logs",
|
||||||
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD}
|
headers={"Authorization": f"Bearer {auth_token}"}
|
||||||
)
|
)
|
||||||
token = login_response.json()["access_token"]
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
response = client.get(
|
assert "data" in data
|
||||||
"/api/v1/logs",
|
assert data["total"] >= 0
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
assert isinstance(data["data"], list)
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert "data" in data
|
|
||||||
assert data["total"] >= 0
|
|
||||||
assert isinstance(data["data"], list)
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
@settings(**_FUZZ_SETTINGS)
|
@settings(**_FUZZ_SETTINGS)
|
||||||
@given(
|
@given(
|
||||||
limit=st.integers(min_value=-2000, max_value=5000),
|
limit=st.integers(min_value=-2000, max_value=5000),
|
||||||
offset=st.integers(min_value=-2000, max_value=5000),
|
offset=st.integers(min_value=-2000, max_value=5000),
|
||||||
search=st.one_of(st.none(), st.text(max_size=2048))
|
search=st.one_of(st.none(), st.text(max_size=2048))
|
||||||
)
|
)
|
||||||
def test_fuzz_get_logs(limit: int, offset: int, search: Optional[str]) -> None:
|
async def test_fuzz_get_logs(client: httpx.AsyncClient, auth_token: str, limit: int, offset: int, search: Optional[str]) -> None:
|
||||||
with TestClient(app) as _client:
|
_params: dict[str, Any] = {"limit": limit, "offset": offset}
|
||||||
_login_resp: httpx.Response = _client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
if search is not None:
|
||||||
_token: str = _login_resp.json()["access_token"]
|
_params["search"] = search
|
||||||
|
|
||||||
_params: dict[str, Any] = {"limit": limit, "offset": offset}
|
_response: httpx.Response = await client.get(
|
||||||
if search is not None:
|
"/api/v1/logs",
|
||||||
_params["search"] = search
|
params=_params,
|
||||||
|
headers={"Authorization": f"Bearer {auth_token}"}
|
||||||
_response: httpx.Response = _client.get(
|
)
|
||||||
"/api/v1/logs",
|
|
||||||
params=_params,
|
assert _response.status_code in (200, 422)
|
||||||
headers={"Authorization": f"Bearer {_token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert _response.status_code in (200, 422)
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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 decnet.web.sqlite_repository import SQLiteRepository
|
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -30,11 +30,13 @@ def _log(decky="d", service="ssh", ip="1.2.3.4", timestamp=None):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
async def test_histogram_empty_db(repo):
|
async def test_histogram_empty_db(repo):
|
||||||
result = await repo.get_log_histogram()
|
result = await repo.get_log_histogram()
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
@freeze_time("2026-04-09 12:00:00")
|
@freeze_time("2026-04-09 12:00:00")
|
||||||
async def test_histogram_single_bucket(repo):
|
async def test_histogram_single_bucket(repo):
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
@@ -48,6 +50,7 @@ async def test_histogram_single_bucket(repo):
|
|||||||
assert result[0]["count"] == 5
|
assert result[0]["count"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
@freeze_time("2026-04-09 12:00:00")
|
@freeze_time("2026-04-09 12:00:00")
|
||||||
async def test_histogram_two_buckets(repo):
|
async def test_histogram_two_buckets(repo):
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
@@ -65,6 +68,7 @@ async def test_histogram_two_buckets(repo):
|
|||||||
assert counts == {3, 7}
|
assert counts == {3, 7}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
@freeze_time("2026-04-09 12:00:00")
|
@freeze_time("2026-04-09 12:00:00")
|
||||||
async def test_histogram_respects_start_end_filter(repo):
|
async def test_histogram_respects_start_end_filter(repo):
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
@@ -82,6 +86,7 @@ async def test_histogram_respects_start_end_filter(repo):
|
|||||||
assert total == 1
|
assert total == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
@freeze_time("2026-04-09 12:00:00")
|
@freeze_time("2026-04-09 12:00:00")
|
||||||
async def test_histogram_search_filter(repo):
|
async def test_histogram_search_filter(repo):
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
|||||||
@@ -1,58 +1,48 @@
|
|||||||
from typing import Any
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from decnet.web.api import app
|
|
||||||
from hypothesis import given, strategies as st, settings
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from typing import Any
|
||||||
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
|
from ..conftest import _FUZZ_SETTINGS
|
||||||
|
from hypothesis import given, strategies as st, settings
|
||||||
|
|
||||||
def test_get_stats_unauthorized() -> None:
|
@pytest.mark.anyio
|
||||||
with TestClient(app) as client:
|
async def test_get_stats_unauthorized(client: httpx.AsyncClient) -> None:
|
||||||
response = client.get("/api/v1/stats")
|
response = await client.get("/api/v1/stats")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_get_stats_success() -> None:
|
@pytest.mark.anyio
|
||||||
with TestClient(app) as client:
|
async def test_get_stats_success(client: httpx.AsyncClient, auth_token: str) -> None:
|
||||||
login_response = client.post(
|
response = await client.get(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/stats",
|
||||||
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD}
|
headers={"Authorization": f"Bearer {auth_token}"}
|
||||||
)
|
)
|
||||||
token = login_response.json()["access_token"]
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
response = client.get(
|
assert "total_logs" in data
|
||||||
"/api/v1/stats",
|
assert "unique_attackers" in data
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
assert "active_deckies" in data
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert "total_logs" in data
|
|
||||||
assert "unique_attackers" in data
|
|
||||||
assert "active_deckies" in data
|
|
||||||
|
|
||||||
def test_stats_includes_deployed_count(mock_state_file):
|
@pytest.mark.anyio
|
||||||
with TestClient(app) as _client:
|
async def test_stats_includes_deployed_count(mock_state_file, client: httpx.AsyncClient, auth_token: str):
|
||||||
_login_resp = _client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
_response = await client.get("/api/v1/stats", headers={"Authorization": f"Bearer {auth_token}"})
|
||||||
_token = _login_resp.json()["access_token"]
|
assert _response.status_code == 200
|
||||||
|
_data = _response.json()
|
||||||
_response = _client.get("/api/v1/stats", headers={"Authorization": f"Bearer {_token}"})
|
assert "deployed_deckies" in _data
|
||||||
assert _response.status_code == 200
|
assert _data["deployed_deckies"] == 2
|
||||||
_data = _response.json()
|
|
||||||
assert "deployed_deckies" in _data
|
|
||||||
assert _data["deployed_deckies"] == 2
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
@settings(**_FUZZ_SETTINGS)
|
@settings(**_FUZZ_SETTINGS)
|
||||||
@given(
|
@given(
|
||||||
token=st.text(min_size=0, max_size=4096)
|
token=st.text(min_size=0, max_size=4096)
|
||||||
)
|
)
|
||||||
def test_fuzz_auth_header(token: str) -> None:
|
async def test_fuzz_auth_header(client: httpx.AsyncClient, token: str) -> None:
|
||||||
"""Fuzz the Authorization header with full unicode noise."""
|
"""Fuzz the Authorization header with full unicode noise."""
|
||||||
with TestClient(app) as _client:
|
try:
|
||||||
try:
|
_response: httpx.Response = await client.get(
|
||||||
_response: httpx.Response = _client.get(
|
"/api/v1/stats",
|
||||||
"/api/v1/stats",
|
headers={"Authorization": f"Bearer {token}"}
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
)
|
||||||
)
|
assert _response.status_code in (401, 422)
|
||||||
assert _response.status_code in (401, 422)
|
except (UnicodeEncodeError, httpx.InvalidURL, httpx.CookieConflict):
|
||||||
except (UnicodeEncodeError, httpx.InvalidURL, httpx.CookieConflict):
|
# Expected client-side rejection of invalid header characters
|
||||||
# Expected client-side rejection of invalid header characters
|
pass
|
||||||
pass
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ covering DEBT-006 (zero test coverage on the database layer).
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
from decnet.web.sqlite_repository import SQLiteRepository
|
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -13,6 +13,7 @@ def repo(tmp_path):
|
|||||||
return SQLiteRepository(db_path=str(tmp_path / "test.db"))
|
return SQLiteRepository(db_path=str(tmp_path / "test.db"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
async def test_add_and_get_log(repo):
|
async def test_add_and_get_log(repo):
|
||||||
await repo.add_log({
|
await repo.add_log({
|
||||||
"decky": "decky-01",
|
"decky": "decky-01",
|
||||||
@@ -30,6 +31,7 @@ async def test_add_and_get_log(repo):
|
|||||||
assert logs[0]["attacker_ip"] == "10.0.0.1"
|
assert logs[0]["attacker_ip"] == "10.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
async def test_get_total_logs(repo):
|
async def test_get_total_logs(repo):
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
await repo.add_log({
|
await repo.add_log({
|
||||||
@@ -45,6 +47,7 @@ async def test_get_total_logs(repo):
|
|||||||
assert total == 5
|
assert total == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
async def test_search_filter_by_decky(repo):
|
async def test_search_filter_by_decky(repo):
|
||||||
await repo.add_log({"decky": "target", "service": "ssh", "event_type": "connect",
|
await repo.add_log({"decky": "target", "service": "ssh", "event_type": "connect",
|
||||||
"attacker_ip": "1.1.1.1", "raw_line": "x", "fields": "{}", "msg": ""})
|
"attacker_ip": "1.1.1.1", "raw_line": "x", "fields": "{}", "msg": ""})
|
||||||
@@ -56,6 +59,7 @@ async def test_search_filter_by_decky(repo):
|
|||||||
assert logs[0]["decky"] == "target"
|
assert logs[0]["decky"] == "target"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
async def test_search_filter_by_service(repo):
|
async def test_search_filter_by_service(repo):
|
||||||
await repo.add_log({"decky": "d1", "service": "rdp", "event_type": "connect",
|
await repo.add_log({"decky": "d1", "service": "rdp", "event_type": "connect",
|
||||||
"attacker_ip": "1.1.1.1", "raw_line": "x", "fields": "{}", "msg": ""})
|
"attacker_ip": "1.1.1.1", "raw_line": "x", "fields": "{}", "msg": ""})
|
||||||
@@ -67,6 +71,7 @@ async def test_search_filter_by_service(repo):
|
|||||||
assert logs[0]["service"] == "rdp"
|
assert logs[0]["service"] == "rdp"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
async def test_search_filter_by_json_field(repo):
|
async def test_search_filter_by_json_field(repo):
|
||||||
await repo.add_log({"decky": "d1", "service": "ssh", "event_type": "connect",
|
await repo.add_log({"decky": "d1", "service": "ssh", "event_type": "connect",
|
||||||
"attacker_ip": "1.1.1.1", "raw_line": "x",
|
"attacker_ip": "1.1.1.1", "raw_line": "x",
|
||||||
@@ -80,6 +85,7 @@ async def test_search_filter_by_json_field(repo):
|
|||||||
assert json.loads(logs[0]["fields"])["username"] == "root"
|
assert json.loads(logs[0]["fields"])["username"] == "root"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
async def test_get_logs_after_id(repo):
|
async def test_get_logs_after_id(repo):
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
await repo.add_log({"decky": "d", "service": "ssh", "event_type": "connect",
|
await repo.add_log({"decky": "d", "service": "ssh", "event_type": "connect",
|
||||||
@@ -97,6 +103,7 @@ async def test_get_logs_after_id(repo):
|
|||||||
assert len(new_logs) == 1
|
assert len(new_logs) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
async def test_full_text_search(repo):
|
async def test_full_text_search(repo):
|
||||||
await repo.add_log({"decky": "d1", "service": "ssh", "event_type": "connect",
|
await repo.add_log({"decky": "d1", "service": "ssh", "event_type": "connect",
|
||||||
"attacker_ip": "1.1.1.1", "raw_line": "supersecretstring",
|
"attacker_ip": "1.1.1.1", "raw_line": "supersecretstring",
|
||||||
@@ -109,6 +116,7 @@ async def test_full_text_search(repo):
|
|||||||
assert len(logs) == 1
|
assert len(logs) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
async def test_pagination(repo):
|
async def test_pagination(repo):
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
await repo.add_log({"decky": "d", "service": "ssh", "event_type": "connect",
|
await repo.add_log({"decky": "d", "service": "ssh", "event_type": "connect",
|
||||||
@@ -128,6 +136,7 @@ async def test_pagination(repo):
|
|||||||
assert ids1.isdisjoint(ids2)
|
assert ids1.isdisjoint(ids2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
async def test_add_and_get_bounty(repo):
|
async def test_add_and_get_bounty(repo):
|
||||||
await repo.add_bounty({
|
await repo.add_bounty({
|
||||||
"decky": "decky-01",
|
"decky": "decky-01",
|
||||||
@@ -142,6 +151,7 @@ async def test_add_and_get_bounty(repo):
|
|||||||
assert bounties[0]["bounty_type"] == "credentials"
|
assert bounties[0]["bounty_type"] == "credentials"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
async def test_user_lifecycle(repo):
|
async def test_user_lifecycle(repo):
|
||||||
import uuid
|
import uuid
|
||||||
uid = str(uuid.uuid4())
|
uid = str(uuid.uuid4())
|
||||||
|
|||||||
@@ -11,6 +11,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 schemathesis
|
import schemathesis
|
||||||
|
from hypothesis import settings
|
||||||
from schemathesis.checks import not_a_server_error
|
from schemathesis.checks import not_a_server_error
|
||||||
from decnet.web.api import app
|
from decnet.web.api import app
|
||||||
|
|
||||||
@@ -18,5 +19,6 @@ schema = schemathesis.openapi.from_asgi("/openapi.json", app)
|
|||||||
|
|
||||||
|
|
||||||
@schemathesis.pytest.parametrize(api=schema)
|
@schemathesis.pytest.parametrize(api=schema)
|
||||||
|
@settings(max_examples=5, deadline=None)
|
||||||
def test_schema_compliance(case):
|
def test_schema_compliance(case):
|
||||||
case.call_and_validate(checks=[not_a_server_error])
|
case.call_and_validate(checks=[not_a_server_error])
|
||||||
|
|||||||
Reference in New Issue
Block a user