The per-request SELECT users WHERE uuid=? in require_role was the hidden tax behind every authed endpoint — it kept _execute at ~60% across the profile even after the page caches landed. Even /health (with its DB and Docker probes cached) was still 52% _execute from this one query. - dependencies.py: 10s TTL cache on get_user_by_uuid, well below JWT expiry. invalidate_user_cache(uuid) is called on password change, role change, and user delete. - api_get_config.py: 5s TTL cache on the admin branch's list_users() (previously fetched every /config call). Invalidated on user create/update/delete. - api_change_pass.py + api_manage_users.py: invalidation hooks on all user-mutating endpoints.
200 lines
7.3 KiB
Python
200 lines
7.3 KiB
Python
import os
|
|
import json
|
|
import uuid as _uuid
|
|
import pytest
|
|
from typing import Any, AsyncGenerator
|
|
from pathlib import Path
|
|
from sqlmodel import SQLModel
|
|
import httpx
|
|
from hypothesis import HealthCheck
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import StaticPool
|
|
import os as _os
|
|
|
|
|
|
def pytest_ignore_collect(collection_path, config):
|
|
"""Skip test_schemathesis.py unless fuzz marker is selected.
|
|
|
|
Its module-level code starts a subprocess server and mutates
|
|
decnet.web.auth.SECRET_KEY, which poisons other test suites.
|
|
"""
|
|
if collection_path.name == "test_schemathesis.py":
|
|
markexpr = config.getoption("markexpr", default="")
|
|
if "fuzz" not in markexpr:
|
|
return True
|
|
|
|
# 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.models import User
|
|
from decnet.web.auth import get_password_hash
|
|
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
|
import decnet.config
|
|
|
|
VIEWER_USERNAME = "testviewer"
|
|
VIEWER_PASSWORD = "viewer-pass-123"
|
|
|
|
|
|
@pytest.fixture(scope="function", autouse=True)
|
|
async def setup_db(monkeypatch) -> AsyncGenerator[None, None]:
|
|
# StaticPool holds one connection forever — :memory: stays alive for the whole test
|
|
engine = create_async_engine(
|
|
"sqlite+aiosqlite:///:memory:",
|
|
connect_args={"check_same_thread": False},
|
|
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)
|
|
|
|
# Reset per-request TTL caches so they don't leak across tests
|
|
from decnet.web.router.health import api_get_health as _h
|
|
from decnet.web.router.config import api_get_config as _c
|
|
from decnet.web.router.stats import api_get_stats as _s
|
|
from decnet.web.router.logs import api_get_logs as _l
|
|
from decnet.web.router.attackers import api_get_attackers as _a
|
|
from decnet.web.router.bounty import api_get_bounties as _b
|
|
from decnet.web.router.logs import api_get_histogram as _lh
|
|
from decnet.web.router.fleet import api_get_deckies as _d
|
|
from decnet.web import dependencies as _deps
|
|
_h._reset_db_cache()
|
|
_c._reset_state_cache()
|
|
_deps._reset_user_cache()
|
|
_s._reset_stats_cache()
|
|
_l._reset_total_cache()
|
|
_a._reset_total_cache()
|
|
_b._reset_bounty_cache()
|
|
_lh._reset_histogram_cache()
|
|
_d._reset_deckies_cache()
|
|
|
|
# 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
|
|
async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as ac:
|
|
yield ac
|
|
|
|
@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})
|
|
token = resp.json()["access_token"]
|
|
# Clear must_change_password so this token passes server-side enforcement on all other endpoints.
|
|
await client.post(
|
|
"/api/v1/auth/change-password",
|
|
json={"old_password": DECNET_ADMIN_PASSWORD, "new_password": DECNET_ADMIN_PASSWORD},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
resp2 = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
|
return resp2.json()["access_token"]
|
|
|
|
@pytest.fixture
|
|
async def viewer_token(client, setup_db):
|
|
"""Seed a viewer user and return their auth token."""
|
|
async with repo.session_factory() as session:
|
|
result = await session.execute(
|
|
select(User).where(User.username == VIEWER_USERNAME)
|
|
)
|
|
if not result.scalar_one_or_none():
|
|
session.add(User(
|
|
uuid=str(_uuid.uuid4()),
|
|
username=VIEWER_USERNAME,
|
|
password_hash=get_password_hash(VIEWER_PASSWORD),
|
|
role="viewer",
|
|
must_change_password=False,
|
|
))
|
|
await session.commit()
|
|
|
|
resp = await client.post("/api/v1/auth/login", json={
|
|
"username": VIEWER_USERNAME,
|
|
"password": VIEWER_PASSWORD,
|
|
})
|
|
return resp.json()["access_token"]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
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(patch_state_file: Path):
|
|
_test_state = {
|
|
"config": {
|
|
"mode": "unihost",
|
|
"interface": "eth0",
|
|
"subnet": "192.168.1.0/24",
|
|
"gateway": "192.168.1.1",
|
|
"deckies": [
|
|
{
|
|
"name": "test-decky-1",
|
|
"ip": "192.168.1.10",
|
|
"services": ["ssh"],
|
|
"distro": "debian",
|
|
"base_image": "debian",
|
|
"hostname": "test-host-1",
|
|
"service_config": {"ssh": {"banner": "SSH-2.0-OpenSSH_8.9"}},
|
|
"archetype": "deaddeck",
|
|
"nmap_os": "linux",
|
|
"build_base": "debian:bookworm-slim",
|
|
"mutate_interval": 30,
|
|
"last_mutated": 0.0
|
|
},
|
|
{
|
|
"name": "test-decky-2",
|
|
"ip": "192.168.1.11",
|
|
"services": ["http"],
|
|
"distro": "ubuntu",
|
|
"base_image": "ubuntu",
|
|
"hostname": "test-host-2",
|
|
"service_config": {},
|
|
"archetype": None,
|
|
"nmap_os": "linux",
|
|
"build_base": "debian:bookworm-slim",
|
|
"mutate_interval": 30,
|
|
"last_mutated": 0.0
|
|
}
|
|
],
|
|
"log_target": None,
|
|
"log_file": "test.log",
|
|
"ipvlan": False,
|
|
"mutate_interval": 30
|
|
},
|
|
"compose_path": "test-compose.yml"
|
|
}
|
|
patch_state_file.write_text(json.dumps(_test_state))
|
|
yield _test_state
|
|
|
|
# Share fuzz settings across API tests
|
|
# FUZZ_EXAMPLES: keep low for dev speed; bump via HYPOTHESIS_MAX_EXAMPLES env var in CI
|
|
_FUZZ_EXAMPLES = int(_os.environ.get("HYPOTHESIS_MAX_EXAMPLES", "10"))
|
|
_FUZZ_SETTINGS: dict[str, Any] = {
|
|
"max_examples": _FUZZ_EXAMPLES,
|
|
"deadline": None,
|
|
"suppress_health_check": [HealthCheck.function_scoped_fixture],
|
|
}
|