merge testing->tomerge/main #7
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
result = runner.invoke(app, ["web"])
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user