diff --git a/tests/api/conftest.py b/tests/api/conftest.py index ed6476f..e0860d5 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -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" diff --git a/tests/live/test_service_isolation_live.py b/tests/live/test_service_isolation_live.py index 7bdfcb7..d14824d 100644 --- a/tests/live/test_service_isolation_live.py +++ b/tests/live/test_service_isolation_live.py @@ -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, diff --git a/tests/test_api_attackers.py b/tests/test_api_attackers.py index 151f860..82022eb 100644 --- a/tests/test_api_attackers.py +++ b/tests/test_api_attackers.py @@ -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 diff --git a/tests/test_attacker_worker.py b/tests/test_attacker_worker.py index 7c7ceaa..bdc7502 100644 --- a/tests/test_attacker_worker.py +++ b/tests/test_attacker_worker.py @@ -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) diff --git a/tests/test_base_repo.py b/tests/test_base_repo.py index 4d00572..cb04ac9 100644 --- a/tests/test_base_repo.py +++ b/tests/test_base_repo.py @@ -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() diff --git a/tests/test_cli.py b/tests/test_cli.py index 2cbebc5..36ca5f4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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() diff --git a/tests/test_service_isolation.py b/tests/test_service_isolation.py index 880b6e1..42133a1 100644 --- a/tests/test_service_isolation.py +++ b/tests/test_service_isolation.py @@ -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