test: update existing test suites for refactored codebase

- test_api_attackers.py: update for BaseRepository interface
- test_attacker_worker.py: full test suite for worker logic (formerly in module)
- test_base_repo.py: repository interface conformance tests
- test_cli.py: CLI enhancements (randomize-services, selective deployment)
- test_service_isolation.py: isolation validation tests
- api/conftest.py: fixture updates for RBAC-gated endpoints
- live/test_service_isolation_live.py: live integration tests
This commit is contained in:
2026-04-15 12:51:26 -04:00
parent 7d10b78d50
commit dd4e2aad91
7 changed files with 108 additions and 42 deletions

View File

@@ -23,6 +23,9 @@ 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]:
@@ -76,6 +79,30 @@ async def auth_token(client: httpx.AsyncClient) -> str:
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"

View File

@@ -36,7 +36,7 @@ from decnet.collector.worker import ( # noqa: E402
is_service_container,
)
from decnet.web.ingester import log_ingestion_worker # noqa: E402
from decnet.web.attacker_worker import ( # noqa: E402
from decnet.profiler.worker import ( # noqa: E402
attacker_profile_worker,
_WorkerState,
_incremental_update,

View File

@@ -58,10 +58,11 @@ class TestGetAttackers:
with patch("decnet.web.router.attackers.api_get_attackers.repo") as mock_repo:
mock_repo.get_attackers = AsyncMock(return_value=[sample])
mock_repo.get_total_attackers = AsyncMock(return_value=1)
mock_repo.get_behaviors_for_ips = AsyncMock(return_value={})
result = await get_attackers(
limit=50, offset=0, search=None, sort_by="recent",
current_user="test-user",
user={"uuid": "test-user", "role": "viewer"},
)
assert result["total"] == 1
@@ -77,10 +78,11 @@ class TestGetAttackers:
with patch("decnet.web.router.attackers.api_get_attackers.repo") as mock_repo:
mock_repo.get_attackers = AsyncMock(return_value=[])
mock_repo.get_total_attackers = AsyncMock(return_value=0)
mock_repo.get_behaviors_for_ips = AsyncMock(return_value={})
await get_attackers(
limit=50, offset=0, search="192.168", sort_by="recent",
current_user="test-user",
user={"uuid": "test-user", "role": "viewer"},
)
mock_repo.get_attackers.assert_awaited_once_with(
@@ -95,10 +97,11 @@ class TestGetAttackers:
with patch("decnet.web.router.attackers.api_get_attackers.repo") as mock_repo:
mock_repo.get_attackers = AsyncMock(return_value=[])
mock_repo.get_total_attackers = AsyncMock(return_value=0)
mock_repo.get_behaviors_for_ips = AsyncMock(return_value={})
await get_attackers(
limit=50, offset=0, search="null", sort_by="recent",
current_user="test-user",
user={"uuid": "test-user", "role": "viewer"},
)
mock_repo.get_attackers.assert_awaited_once_with(
@@ -112,10 +115,11 @@ class TestGetAttackers:
with patch("decnet.web.router.attackers.api_get_attackers.repo") as mock_repo:
mock_repo.get_attackers = AsyncMock(return_value=[])
mock_repo.get_total_attackers = AsyncMock(return_value=0)
mock_repo.get_behaviors_for_ips = AsyncMock(return_value={})
await get_attackers(
limit=50, offset=0, search=None, sort_by="active",
current_user="test-user",
user={"uuid": "test-user", "role": "viewer"},
)
mock_repo.get_attackers.assert_awaited_once_with(
@@ -129,10 +133,11 @@ class TestGetAttackers:
with patch("decnet.web.router.attackers.api_get_attackers.repo") as mock_repo:
mock_repo.get_attackers = AsyncMock(return_value=[])
mock_repo.get_total_attackers = AsyncMock(return_value=0)
mock_repo.get_behaviors_for_ips = AsyncMock(return_value={})
await get_attackers(
limit=50, offset=0, search="", sort_by="recent",
current_user="test-user",
user={"uuid": "test-user", "role": "viewer"},
)
mock_repo.get_attackers.assert_awaited_once_with(
@@ -146,10 +151,11 @@ class TestGetAttackers:
with patch("decnet.web.router.attackers.api_get_attackers.repo") as mock_repo:
mock_repo.get_attackers = AsyncMock(return_value=[])
mock_repo.get_total_attackers = AsyncMock(return_value=0)
mock_repo.get_behaviors_for_ips = AsyncMock(return_value={})
await get_attackers(
limit=50, offset=0, search=None, sort_by="recent",
service="https", current_user="test-user",
service="https", user={"uuid": "test-user", "role": "viewer"},
)
mock_repo.get_attackers.assert_awaited_once_with(
@@ -168,8 +174,9 @@ class TestGetAttackerDetail:
sample = _sample_attacker()
with patch("decnet.web.router.attackers.api_get_attacker_detail.repo") as mock_repo:
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample)
mock_repo.get_attacker_behavior = AsyncMock(return_value=None)
result = await get_attacker_detail(uuid="att-uuid-1", current_user="test-user")
result = await get_attacker_detail(uuid="att-uuid-1", user={"uuid": "test-user", "role": "viewer"})
assert result["uuid"] == "att-uuid-1"
assert result["ip"] == "1.2.3.4"
@@ -184,7 +191,7 @@ class TestGetAttackerDetail:
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc_info:
await get_attacker_detail(uuid="nonexistent", current_user="test-user")
await get_attacker_detail(uuid="nonexistent", user={"uuid": "test-user", "role": "viewer"})
assert exc_info.value.status_code == 404
@@ -195,8 +202,9 @@ class TestGetAttackerDetail:
sample = _sample_attacker()
with patch("decnet.web.router.attackers.api_get_attacker_detail.repo") as mock_repo:
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample)
mock_repo.get_attacker_behavior = AsyncMock(return_value=None)
result = await get_attacker_detail(uuid="att-uuid-1", current_user="test-user")
result = await get_attacker_detail(uuid="att-uuid-1", user={"uuid": "test-user", "role": "viewer"})
assert isinstance(result["services"], list)
assert isinstance(result["deckies"], list)
@@ -222,7 +230,7 @@ class TestGetAttackerCommands:
result = await get_attacker_commands(
uuid="att-uuid-1", limit=50, offset=0, service=None,
current_user="test-user",
user={"uuid": "test-user", "role": "viewer"},
)
assert result["total"] == 2
@@ -241,7 +249,7 @@ class TestGetAttackerCommands:
await get_attacker_commands(
uuid="att-uuid-1", limit=50, offset=0, service="ssh",
current_user="test-user",
user={"uuid": "test-user", "role": "viewer"},
)
mock_repo.get_attacker_commands.assert_awaited_once_with(
@@ -258,7 +266,7 @@ class TestGetAttackerCommands:
with pytest.raises(HTTPException) as exc_info:
await get_attacker_commands(
uuid="nonexistent", limit=50, offset=0, service=None,
current_user="test-user",
user={"uuid": "test-user", "role": "viewer"},
)
assert exc_info.value.status_code == 404

View File

@@ -1,5 +1,5 @@
"""
Tests for decnet/web/attacker_worker.py
Tests for decnet/attacker/worker.py
Covers:
- _cold_start(): full build on first run, cursor persistence
@@ -22,7 +22,7 @@ import pytest
from decnet.correlation.parser import LogEvent
from decnet.logging.syslog_formatter import SEVERITY_INFO, format_rfc5424
from decnet.web.attacker_worker import (
from decnet.profiler.worker import (
_BATCH_SIZE,
_STATE_KEY,
_WorkerState,
@@ -104,7 +104,8 @@ def _make_repo(logs=None, bounties=None, bounties_for_ips=None, max_log_id=0, sa
repo.get_logs_after_id = AsyncMock(return_value=[])
repo.get_state = AsyncMock(return_value=saved_state)
repo.set_state = AsyncMock()
repo.upsert_attacker = AsyncMock()
repo.upsert_attacker = AsyncMock(return_value="mock-uuid")
repo.upsert_attacker_behavior = AsyncMock()
return repo
@@ -584,8 +585,8 @@ class TestAttackerProfileWorker:
async def bad_update(_repo, _state):
raise RuntimeError("DB exploded")
with patch("decnet.web.attacker_worker.asyncio.sleep", side_effect=fake_sleep):
with patch("decnet.web.attacker_worker._incremental_update", side_effect=bad_update):
with patch("decnet.profiler.worker.asyncio.sleep", side_effect=fake_sleep):
with patch("decnet.profiler.worker._incremental_update", side_effect=bad_update):
with pytest.raises(asyncio.CancelledError):
await attacker_profile_worker(repo)
@@ -605,8 +606,8 @@ class TestAttackerProfileWorker:
async def mock_update(_repo, _state):
update_calls.append(True)
with patch("decnet.web.attacker_worker.asyncio.sleep", side_effect=fake_sleep):
with patch("decnet.web.attacker_worker._incremental_update", side_effect=mock_update):
with patch("decnet.profiler.worker.asyncio.sleep", side_effect=fake_sleep):
with patch("decnet.profiler.worker._incremental_update", side_effect=mock_update):
with pytest.raises(asyncio.CancelledError):
await attacker_profile_worker(repo)

View File

@@ -26,11 +26,18 @@ class DummyRepo(BaseRepository):
async def get_logs_after_id(self, last_id, limit=500): await super().get_logs_after_id(last_id, limit)
async def get_all_bounties_by_ip(self): await super().get_all_bounties_by_ip()
async def get_bounties_for_ips(self, ips): await super().get_bounties_for_ips(ips)
async def upsert_attacker(self, d): await super().upsert_attacker(d)
async def upsert_attacker(self, d): await super().upsert_attacker(d); return ""
async def upsert_attacker_behavior(self, u, d): await super().upsert_attacker_behavior(u, d)
async def get_attacker_behavior(self, u): await super().get_attacker_behavior(u)
async def get_behaviors_for_ips(self, ips): await super().get_behaviors_for_ips(ips)
async def get_attacker_by_uuid(self, u): await super().get_attacker_by_uuid(u)
async def get_attackers(self, **kw): await super().get_attackers(**kw)
async def get_total_attackers(self, **kw): await super().get_total_attackers(**kw)
async def get_attacker_commands(self, **kw): await super().get_attacker_commands(**kw)
async def list_users(self): await super().list_users()
async def delete_user(self, u): await super().delete_user(u)
async def update_user_role(self, u, r): await super().update_user_role(u, r)
async def purge_logs_and_bounties(self): await super().purge_logs_and_bounties()
@pytest.mark.asyncio
async def test_base_repo_coverage():
@@ -57,7 +64,14 @@ async def test_base_repo_coverage():
await dr.get_all_bounties_by_ip()
await dr.get_bounties_for_ips({"1.1.1.1"})
await dr.upsert_attacker({})
await dr.upsert_attacker_behavior("a", {})
await dr.get_attacker_behavior("a")
await dr.get_behaviors_for_ips({"1.1.1.1"})
await dr.get_attacker_by_uuid("a")
await dr.get_attackers()
await dr.get_total_attackers()
await dr.get_attacker_commands(uuid="a")
await dr.list_users()
await dr.delete_user("a")
await dr.update_user_role("a", "admin")
await dr.purge_logs_and_bounties()

View File

@@ -181,7 +181,7 @@ class TestTeardownCommand:
result = runner.invoke(app, ["teardown"])
assert result.exit_code == 1
@patch("decnet.cli._kill_api")
@patch("decnet.cli._kill_all_services")
@patch("decnet.engine.teardown")
def test_teardown_all(self, mock_teardown, mock_kill):
result = runner.invoke(app, ["teardown", "--all"])
@@ -275,13 +275,29 @@ class TestWebCommand:
assert result.exit_code == 1
assert "Frontend build not found" in result.stdout
@patch("socketserver.TCPServer")
@patch("os.chdir")
@patch("pathlib.Path.exists", return_value=True)
def test_web_success(self, mock_exists, mock_chdir, mock_server):
# We need to simulate a KeyboardInterrupt to stop serve_forever
mock_server.return_value.__enter__.return_value.serve_forever.side_effect = KeyboardInterrupt
def test_web_success(self):
with (
patch("pathlib.Path.exists", return_value=True),
patch("os.chdir"),
patch(
"socketserver.TCPServer.__init__",
lambda self, *a, **kw: None,
),
patch(
"socketserver.TCPServer.__enter__",
lambda self: self,
),
patch(
"socketserver.TCPServer.__exit__",
lambda self, *a: None,
),
patch(
"socketserver.TCPServer.serve_forever",
side_effect=KeyboardInterrupt,
),
):
result = runner.invoke(app, ["web"])
assert result.exit_code == 0
assert "Serving DECNET Web Dashboard" in result.stdout
@@ -320,13 +336,13 @@ class TestApiCommand:
assert result.exit_code == 0
# ── _kill_api ─────────────────────────────────────────────────────────────────
# ── _kill_all_services ────────────────────────────────────────────────────────
class TestKillApi:
class TestKillAllServices:
@patch("os.kill")
@patch("psutil.process_iter")
def test_kills_matching_processes(self, mock_iter, mock_kill):
from decnet.cli import _kill_api
from decnet.cli import _kill_all_services
mock_uvicorn = MagicMock()
mock_uvicorn.info = {
"pid": 111, "name": "python",
@@ -343,21 +359,21 @@ class TestKillApi:
"cmdline": ["python", "-m", "decnet.cli", "collect", "--log-file", "/tmp/decnet.log"],
}
mock_iter.return_value = [mock_uvicorn, mock_mutate, mock_collector]
_kill_api()
_kill_all_services()
assert mock_kill.call_count == 3
@patch("psutil.process_iter")
def test_no_matching_processes(self, mock_iter):
from decnet.cli import _kill_api
from decnet.cli import _kill_all_services
mock_proc = MagicMock()
mock_proc.info = {"pid": 1, "name": "bash", "cmdline": ["bash"]}
mock_iter.return_value = [mock_proc]
_kill_api()
_kill_all_services()
@patch("psutil.process_iter")
def test_handles_empty_cmdline(self, mock_iter):
from decnet.cli import _kill_api
from decnet.cli import _kill_all_services
mock_proc = MagicMock()
mock_proc.info = {"pid": 1, "name": "bash", "cmdline": None}
mock_iter.return_value = [mock_proc]
_kill_api()
_kill_all_services()

View File

@@ -184,7 +184,7 @@ class TestAttackerWorkerIsolation:
@pytest.mark.asyncio
async def test_attacker_worker_survives_db_error(self):
"""Attacker worker must catch DB errors and continue looping."""
from decnet.web.attacker_worker import attacker_profile_worker
from decnet.profiler import attacker_profile_worker
mock_repo = MagicMock()
mock_repo.get_all_logs_raw = AsyncMock(side_effect=Exception("DB is locked"))
@@ -199,7 +199,7 @@ class TestAttackerWorkerIsolation:
if iterations >= 3:
raise asyncio.CancelledError()
with patch("decnet.web.attacker_worker.asyncio.sleep", side_effect=_controlled_sleep):
with patch("decnet.profiler.worker.asyncio.sleep", side_effect=_controlled_sleep):
task = asyncio.create_task(attacker_profile_worker(mock_repo))
with pytest.raises(asyncio.CancelledError):
await task
@@ -209,7 +209,7 @@ class TestAttackerWorkerIsolation:
@pytest.mark.asyncio
async def test_attacker_worker_survives_empty_db(self):
"""Attacker worker must handle an empty database gracefully."""
from decnet.web.attacker_worker import _WorkerState, _incremental_update
from decnet.profiler.worker import _WorkerState, _incremental_update
mock_repo = MagicMock()
mock_repo.get_all_logs_raw = AsyncMock(return_value=[])
@@ -433,7 +433,7 @@ class TestCascadeIsolation:
@pytest.mark.asyncio
async def test_ingester_failure_does_not_kill_attacker(self):
"""When ingester dies, attacker worker must keep running independently."""
from decnet.web.attacker_worker import attacker_profile_worker
from decnet.profiler import attacker_profile_worker
mock_repo = MagicMock()
mock_repo.get_all_logs_raw = AsyncMock(return_value=[])
@@ -449,7 +449,7 @@ class TestCascadeIsolation:
if iterations >= 3:
raise asyncio.CancelledError()
with patch("decnet.web.attacker_worker.asyncio.sleep", side_effect=_controlled_sleep):
with patch("decnet.profiler.worker.asyncio.sleep", side_effect=_controlled_sleep):
task = asyncio.create_task(attacker_profile_worker(mock_repo))
with pytest.raises(asyncio.CancelledError):
await task