refactor(tests): move flat tests/*.py into per-subsystem subfolders
Groups every flat test_*.py under the module it exercises, matching the
existing tests/{profiler,sniffer,prober,collector,correlation,cli,web,
topology,swarm,bus,updater,api,docker,geoip,...} layout. New folders:
services/, fleet/, config/, logging/, db/ (+ db/mysql/), telemetry/,
mutator/, core/.
Path-dependent __file__ references bumped an extra .parent in three
files that moved one level deeper:
- tests/sniffer/test_sniffer_ja3.py (template path)
- tests/services/test_ssh_capture_emit.py (template path)
- tests/cli/test_mode_gating.py (REPO root)
- tests/web/test_env_lazy_jwt.py (repo var)
Also drops two SQLite runtime artifacts (test_decnet.db-{shm,wal}) that
were leaking into the repo from a previous test run.
Fixes two test_service_isolation cases that patched asyncio.sleep (no
longer on the profiler main-loop hot path — same pre-existing bug I
fixed earlier in test_attacker_worker.py) by patching asyncio.wait_for
and passing interval=0.
This commit is contained in:
66
tests/web/test_admin_seed.py
Normal file
66
tests/web/test_admin_seed.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Tests for _ensure_admin_user env-drift self-healing.
|
||||
|
||||
Scenario: DECNET_ADMIN_PASSWORD changes between runs while the SQLite DB
|
||||
persists on disk. Previously _ensure_admin_user was strictly insert-if-missing,
|
||||
so the stale hash from the first seed locked out every subsequent login.
|
||||
|
||||
Contract: if the admin still has must_change_password=True (they never
|
||||
finalized their own password), the stored hash re-syncs from the env.
|
||||
Once the admin picks a real password, we never touch it.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from decnet.web.auth import verify_password
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_seeded_on_empty_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("decnet.web.db.sqlmodel_repo.DECNET_ADMIN_PASSWORD", "first")
|
||||
repo = SQLiteRepository(db_path=str(tmp_path / "t.db"))
|
||||
await repo.initialize()
|
||||
user = await repo.get_user_by_username("admin")
|
||||
assert user is not None
|
||||
assert verify_password("first", user["password_hash"])
|
||||
assert user["must_change_password"] is True or user["must_change_password"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_password_resyncs_when_not_finalized(tmp_path, monkeypatch):
|
||||
db = str(tmp_path / "t.db")
|
||||
|
||||
monkeypatch.setattr("decnet.web.db.sqlmodel_repo.DECNET_ADMIN_PASSWORD", "first")
|
||||
r1 = SQLiteRepository(db_path=db)
|
||||
await r1.initialize()
|
||||
|
||||
monkeypatch.setattr("decnet.web.db.sqlmodel_repo.DECNET_ADMIN_PASSWORD", "second")
|
||||
r2 = SQLiteRepository(db_path=db)
|
||||
await r2.initialize()
|
||||
|
||||
user = await r2.get_user_by_username("admin")
|
||||
assert verify_password("second", user["password_hash"])
|
||||
assert not verify_password("first", user["password_hash"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalized_admin_password_is_preserved(tmp_path, monkeypatch):
|
||||
db = str(tmp_path / "t.db")
|
||||
|
||||
monkeypatch.setattr("decnet.web.db.sqlmodel_repo.DECNET_ADMIN_PASSWORD", "seed")
|
||||
r1 = SQLiteRepository(db_path=db)
|
||||
await r1.initialize()
|
||||
admin = await r1.get_user_by_username("admin")
|
||||
# Simulate the admin finalising their password via the change-password flow.
|
||||
from decnet.web.auth import get_password_hash
|
||||
await r1.update_user_password(
|
||||
admin["uuid"], get_password_hash("chosen"), must_change_password=False
|
||||
)
|
||||
|
||||
monkeypatch.setattr("decnet.web.db.sqlmodel_repo.DECNET_ADMIN_PASSWORD", "different")
|
||||
r2 = SQLiteRepository(db_path=db)
|
||||
await r2.initialize()
|
||||
|
||||
user = await r2.get_user_by_username("admin")
|
||||
assert verify_password("chosen", user["password_hash"])
|
||||
assert not verify_password("different", user["password_hash"])
|
||||
525
tests/web/test_api_attackers.py
Normal file
525
tests/web/test_api_attackers.py
Normal file
@@ -0,0 +1,525 @@
|
||||
"""
|
||||
Tests for the attacker profile API routes.
|
||||
|
||||
Covers:
|
||||
- GET /attackers: paginated list, search, sort_by
|
||||
- GET /attackers/{uuid}: single profile detail, 404 on missing UUID
|
||||
- Auth enforcement on both endpoints
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from decnet.web.auth import create_access_token
|
||||
from decnet.web.router.attackers.api_get_attackers import _reset_total_cache
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_attackers_cache():
|
||||
_reset_total_cache()
|
||||
yield
|
||||
_reset_total_cache()
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _auth_request(uuid: str = "test-user-uuid") -> MagicMock:
|
||||
token = create_access_token({"uuid": uuid})
|
||||
req = MagicMock()
|
||||
req.headers = {"Authorization": f"Bearer {token}"}
|
||||
return req
|
||||
|
||||
|
||||
def _sample_attacker(uuid: str = "att-uuid-1", ip: str = "1.2.3.4") -> dict:
|
||||
return {
|
||||
"uuid": uuid,
|
||||
"ip": ip,
|
||||
"first_seen": datetime(2026, 4, 1, tzinfo=timezone.utc).isoformat(),
|
||||
"last_seen": datetime(2026, 4, 10, tzinfo=timezone.utc).isoformat(),
|
||||
"event_count": 42,
|
||||
"service_count": 3,
|
||||
"decky_count": 2,
|
||||
"services": ["ssh", "http", "ftp"],
|
||||
"deckies": ["decky-01", "decky-02"],
|
||||
"traversal_path": "decky-01 → decky-02",
|
||||
"is_traversal": True,
|
||||
"bounty_count": 5,
|
||||
"credential_count": 2,
|
||||
"fingerprints": [{"type": "ja3", "hash": "abc"}],
|
||||
"commands": [{"service": "ssh", "decky": "decky-01", "command": "id", "timestamp": "2026-04-01T10:00:00"}],
|
||||
"updated_at": datetime(2026, 4, 10, tzinfo=timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ─── GET /attackers ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestGetAttackers:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_paginated_response(self):
|
||||
from decnet.web.router.attackers.api_get_attackers import get_attackers
|
||||
|
||||
sample = _sample_attacker()
|
||||
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",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert result["total"] == 1
|
||||
assert result["limit"] == 50
|
||||
assert result["offset"] == 0
|
||||
assert len(result["data"]) == 1
|
||||
assert result["data"][0]["uuid"] == "att-uuid-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_parameter_forwarded(self):
|
||||
from decnet.web.router.attackers.api_get_attackers import get_attackers
|
||||
|
||||
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",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
mock_repo.get_attackers.assert_awaited_once_with(
|
||||
limit=50, offset=0, search="192.168", sort_by="recent", service=None,
|
||||
)
|
||||
mock_repo.get_total_attackers.assert_awaited_once_with(search="192.168", service=None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_search_normalized(self):
|
||||
from decnet.web.router.attackers.api_get_attackers import get_attackers
|
||||
|
||||
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",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
mock_repo.get_attackers.assert_awaited_once_with(
|
||||
limit=50, offset=0, search=None, sort_by="recent", service=None,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sort_by_active(self):
|
||||
from decnet.web.router.attackers.api_get_attackers import get_attackers
|
||||
|
||||
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",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
mock_repo.get_attackers.assert_awaited_once_with(
|
||||
limit=50, offset=0, search=None, sort_by="active", service=None,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_search_normalized_to_none(self):
|
||||
from decnet.web.router.attackers.api_get_attackers import get_attackers
|
||||
|
||||
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",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
mock_repo.get_attackers.assert_awaited_once_with(
|
||||
limit=50, offset=0, search=None, sort_by="recent", service=None,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_filter_forwarded(self):
|
||||
from decnet.web.router.attackers.api_get_attackers import get_attackers
|
||||
|
||||
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", user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
mock_repo.get_attackers.assert_awaited_once_with(
|
||||
limit=50, offset=0, search=None, sort_by="recent", service="https",
|
||||
)
|
||||
mock_repo.get_total_attackers.assert_awaited_once_with(search=None, service="https")
|
||||
|
||||
|
||||
# ─── GET /attackers/{uuid} ───────────────────────────────────────────────────
|
||||
|
||||
class TestGetAttackerDetail:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_attacker_by_uuid(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_detail import get_attacker_detail
|
||||
|
||||
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", user={"uuid": "test-user", "role": "viewer"})
|
||||
|
||||
assert result["uuid"] == "att-uuid-1"
|
||||
assert result["ip"] == "1.2.3.4"
|
||||
assert result["is_traversal"] is True
|
||||
assert isinstance(result["commands"], list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_on_unknown_uuid(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_detail import get_attacker_detail
|
||||
|
||||
with patch("decnet.web.router.attackers.api_get_attacker_detail.repo") as mock_repo:
|
||||
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_attacker_detail(uuid="nonexistent", user={"uuid": "test-user", "role": "viewer"})
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deserialized_json_fields(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_detail import get_attacker_detail
|
||||
|
||||
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", user={"uuid": "test-user", "role": "viewer"})
|
||||
|
||||
assert isinstance(result["services"], list)
|
||||
assert isinstance(result["deckies"], list)
|
||||
assert isinstance(result["fingerprints"], list)
|
||||
assert isinstance(result["commands"], list)
|
||||
|
||||
|
||||
# ─── GET /attackers/{uuid}/commands ──────────────────────────────────────────
|
||||
|
||||
class TestGetAttackerCommands:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_paginated_commands(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_commands import get_attacker_commands
|
||||
|
||||
sample = _sample_attacker()
|
||||
cmds = [
|
||||
{"service": "ssh", "decky": "decky-01", "command": "id", "timestamp": "2026-04-01T10:00:00"},
|
||||
{"service": "ssh", "decky": "decky-01", "command": "whoami", "timestamp": "2026-04-01T10:01:00"},
|
||||
]
|
||||
with patch("decnet.web.router.attackers.api_get_attacker_commands.repo") as mock_repo:
|
||||
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample)
|
||||
mock_repo.get_attacker_commands = AsyncMock(return_value={"total": 2, "data": cmds})
|
||||
|
||||
result = await get_attacker_commands(
|
||||
uuid="att-uuid-1", limit=50, offset=0, service=None,
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert result["total"] == 2
|
||||
assert len(result["data"]) == 2
|
||||
assert result["limit"] == 50
|
||||
assert result["offset"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_filter_forwarded(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_commands import get_attacker_commands
|
||||
|
||||
sample = _sample_attacker()
|
||||
with patch("decnet.web.router.attackers.api_get_attacker_commands.repo") as mock_repo:
|
||||
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample)
|
||||
mock_repo.get_attacker_commands = AsyncMock(return_value={"total": 0, "data": []})
|
||||
|
||||
await get_attacker_commands(
|
||||
uuid="att-uuid-1", limit=50, offset=0, service="ssh",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
mock_repo.get_attacker_commands.assert_awaited_once_with(
|
||||
uuid="att-uuid-1", limit=50, offset=0, service="ssh",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_on_unknown_uuid(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_commands import get_attacker_commands
|
||||
|
||||
with patch("decnet.web.router.attackers.api_get_attacker_commands.repo") as mock_repo:
|
||||
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_attacker_commands(
|
||||
uuid="nonexistent", limit=50, offset=0, service=None,
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
# ─── GET /attackers/{uuid}/artifacts ─────────────────────────────────────────
|
||||
|
||||
class TestGetAttackerArtifacts:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_artifacts(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_artifacts import get_attacker_artifacts
|
||||
|
||||
sample = _sample_attacker()
|
||||
rows = [
|
||||
{
|
||||
"id": 1,
|
||||
"timestamp": "2026-04-18T02:22:56+00:00",
|
||||
"decky": "decky-01",
|
||||
"service": "ssh",
|
||||
"event_type": "file_captured",
|
||||
"attacker_ip": "1.2.3.4",
|
||||
"raw_line": "",
|
||||
"msg": "",
|
||||
"fields": json.dumps({
|
||||
"stored_as": "2026-04-18T02:22:56Z_abc123def456_drop.bin",
|
||||
"sha256": "deadbeef" * 8,
|
||||
"size": "4096",
|
||||
"orig_path": "/root/drop.bin",
|
||||
}),
|
||||
},
|
||||
]
|
||||
with patch("decnet.web.router.attackers.api_get_attacker_artifacts.repo") as mock_repo:
|
||||
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample)
|
||||
mock_repo.get_attacker_artifacts = AsyncMock(return_value=rows)
|
||||
|
||||
result = await get_attacker_artifacts(
|
||||
uuid="att-uuid-1",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert result["total"] == 1
|
||||
assert result["data"][0]["decky"] == "decky-01"
|
||||
mock_repo.get_attacker_artifacts.assert_awaited_once_with("att-uuid-1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_on_unknown_uuid(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_artifacts import get_attacker_artifacts
|
||||
|
||||
with patch("decnet.web.router.attackers.api_get_attacker_artifacts.repo") as mock_repo:
|
||||
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_attacker_artifacts(
|
||||
uuid="nonexistent",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
# ─── GET /attackers/{uuid}/transcripts ───────────────────────────────────────
|
||||
|
||||
class TestGetAttackerTranscripts:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_transcripts(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_transcripts import get_attacker_transcripts
|
||||
|
||||
sample = _sample_attacker()
|
||||
rows = [
|
||||
{
|
||||
"id": 1,
|
||||
"timestamp": "2026-04-18T02:22:56+00:00",
|
||||
"decky": "decky-01",
|
||||
"service": "ssh",
|
||||
"event_type": "session_recorded",
|
||||
"attacker_ip": "1.2.3.4",
|
||||
"raw_line": "",
|
||||
"msg": "",
|
||||
"fields": json.dumps({
|
||||
"sid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"service": "ssh",
|
||||
"src_ip": "1.2.3.4",
|
||||
"duration_s": "42",
|
||||
"bytes": "1024",
|
||||
"truncated": "false",
|
||||
"shard_path": "/var/lib/systemd/coredump/transcripts/sessions-2026-04-18.jsonl",
|
||||
}),
|
||||
},
|
||||
]
|
||||
with patch("decnet.web.router.attackers.api_get_attacker_transcripts.repo") as mock_repo:
|
||||
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample)
|
||||
mock_repo.get_attacker_transcripts = AsyncMock(return_value=rows)
|
||||
|
||||
result = await get_attacker_transcripts(
|
||||
uuid="att-uuid-1",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert result["total"] == 1
|
||||
assert result["data"][0]["service"] == "ssh"
|
||||
mock_repo.get_attacker_transcripts.assert_awaited_once_with("att-uuid-1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_on_unknown_uuid(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_transcripts import get_attacker_transcripts
|
||||
|
||||
with patch("decnet.web.router.attackers.api_get_attacker_transcripts.repo") as mock_repo:
|
||||
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_attacker_transcripts(
|
||||
uuid="nonexistent",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
# ─── GET /attackers/{uuid}/smtp-targets ──────────────────────────────────────
|
||||
|
||||
class TestGetAttackerSmtpTargets:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_smtp_targets(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_smtp_targets import (
|
||||
get_attacker_smtp_targets,
|
||||
)
|
||||
|
||||
sample = _sample_attacker()
|
||||
rows = [
|
||||
{
|
||||
"id": 1, "attacker_uuid": "att-uuid-1",
|
||||
"domain": "corp1.com", "count": 5,
|
||||
"first_seen": "2026-04-18T02:22:56+00:00",
|
||||
"last_seen": "2026-04-19T10:15:03+00:00",
|
||||
},
|
||||
]
|
||||
with patch("decnet.web.router.attackers.api_get_attacker_smtp_targets.repo") as mock_repo:
|
||||
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample)
|
||||
mock_repo.list_smtp_targets = AsyncMock(return_value=rows)
|
||||
|
||||
result = await get_attacker_smtp_targets(
|
||||
uuid="att-uuid-1",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert result["total"] == 1
|
||||
assert result["data"][0]["domain"] == "corp1.com"
|
||||
mock_repo.list_smtp_targets.assert_awaited_once_with("att-uuid-1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_on_unknown_uuid(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_smtp_targets import (
|
||||
get_attacker_smtp_targets,
|
||||
)
|
||||
|
||||
with patch("decnet.web.router.attackers.api_get_attacker_smtp_targets.repo") as mock_repo:
|
||||
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_attacker_smtp_targets(
|
||||
uuid="nonexistent",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
# ─── GET /attackers/{uuid}/mail ──────────────────────────────────────────────
|
||||
|
||||
class TestGetAttackerMail:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_stored_mail(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_mail import get_attacker_mail
|
||||
|
||||
sample = _sample_attacker()
|
||||
rows = [
|
||||
{
|
||||
"id": 1,
|
||||
"timestamp": "2026-04-18T02:22:56+00:00",
|
||||
"decky": "decky-01", "service": "smtp",
|
||||
"event_type": "message_stored",
|
||||
"attacker_ip": "1.2.3.4",
|
||||
"raw_line": "", "msg": "",
|
||||
"fields": json.dumps({
|
||||
"stored_as": "2026-04-18T02:22:56Z_abc123def456_ABC123.eml",
|
||||
"sha256": "deadbeef" * 8,
|
||||
"size": "1024",
|
||||
"subject": "URGENT invoice",
|
||||
"from_hdr": "spam@evil.com",
|
||||
"rcpt_to": "<a@corp.com>",
|
||||
"attachment_count": "1",
|
||||
}),
|
||||
},
|
||||
]
|
||||
with patch("decnet.web.router.attackers.api_get_attacker_mail.repo") as mock_repo:
|
||||
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample)
|
||||
mock_repo.get_attacker_stored_mail = AsyncMock(return_value=rows)
|
||||
|
||||
result = await get_attacker_mail(
|
||||
uuid="att-uuid-1",
|
||||
admin={"uuid": "test-admin", "role": "admin"},
|
||||
)
|
||||
|
||||
assert result["total"] == 1
|
||||
assert result["data"][0]["event_type"] == "message_stored"
|
||||
mock_repo.get_attacker_stored_mail.assert_awaited_once_with("att-uuid-1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_on_unknown_uuid(self):
|
||||
from decnet.web.router.attackers.api_get_attacker_mail import get_attacker_mail
|
||||
|
||||
with patch("decnet.web.router.attackers.api_get_attacker_mail.repo") as mock_repo:
|
||||
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_attacker_mail(
|
||||
uuid="nonexistent",
|
||||
admin={"uuid": "test-admin", "role": "admin"},
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
# ─── Auth enforcement ────────────────────────────────────────────────────────
|
||||
|
||||
class TestAttackersAuth:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_requires_auth(self):
|
||||
"""get_current_user dependency raises 401 when called without valid token."""
|
||||
from decnet.web.dependencies import get_current_user
|
||||
|
||||
req = MagicMock()
|
||||
req.headers = {}
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_user(req)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_requires_auth(self):
|
||||
from decnet.web.dependencies import get_current_user
|
||||
|
||||
req = MagicMock()
|
||||
req.headers = {"Authorization": "Bearer bad-token"}
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_user(req)
|
||||
assert exc_info.value.status_code == 401
|
||||
51
tests/web/test_auth_async.py
Normal file
51
tests/web/test_auth_async.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
averify_password / ahash_password run bcrypt on a thread so the event
|
||||
loop can serve other requests while hashing. Contract: they must produce
|
||||
identical results to the sync versions.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from decnet.web.auth import (
|
||||
ahash_password,
|
||||
averify_password,
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ahash_matches_sync_hash_verify():
|
||||
hashed = await ahash_password("hunter2")
|
||||
assert verify_password("hunter2", hashed)
|
||||
assert not verify_password("wrong", hashed)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_averify_matches_sync_verify():
|
||||
hashed = get_password_hash("s3cret")
|
||||
assert await averify_password("s3cret", hashed) is True
|
||||
assert await averify_password("s3cre", hashed) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_averify_does_not_block_loop():
|
||||
"""Two concurrent averify calls should run in parallel (on threads).
|
||||
|
||||
With `asyncio.to_thread`, total wall time is ~max(a, b), not a+b.
|
||||
"""
|
||||
import asyncio, time
|
||||
|
||||
hashed = get_password_hash("x")
|
||||
t0 = time.perf_counter()
|
||||
a, b = await asyncio.gather(
|
||||
averify_password("x", hashed),
|
||||
averify_password("x", hashed),
|
||||
)
|
||||
elapsed = time.perf_counter() - t0
|
||||
assert a and b
|
||||
# Sequential would be ~2× a single verify. Parallel on threads is ~1×.
|
||||
# Single verify is ~250ms at rounds=12. Allow slack for CI noise.
|
||||
single = time.perf_counter()
|
||||
verify_password("x", hashed)
|
||||
single_time = time.perf_counter() - single
|
||||
assert elapsed < 1.7 * single_time, f"concurrent {elapsed:.3f}s vs single {single_time:.3f}s"
|
||||
62
tests/web/test_env_lazy_jwt.py
Normal file
62
tests/web/test_env_lazy_jwt.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""The JWT secret must be lazy: agent/updater subcommands should import
|
||||
`decnet.env` without DECNET_JWT_SECRET being set."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _reimport_env(monkeypatch):
|
||||
monkeypatch.delenv("DECNET_JWT_SECRET", raising=False)
|
||||
for mod in list(sys.modules):
|
||||
if mod == "decnet.env" or mod.startswith("decnet.env."):
|
||||
sys.modules.pop(mod)
|
||||
return importlib.import_module("decnet.env")
|
||||
|
||||
|
||||
def test_env_imports_without_jwt_secret(monkeypatch):
|
||||
env = _reimport_env(monkeypatch)
|
||||
assert hasattr(env, "DECNET_API_PORT")
|
||||
|
||||
|
||||
def test_jwt_secret_access_returns_value_when_set(monkeypatch):
|
||||
monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32)
|
||||
env = _reimport_env(monkeypatch)
|
||||
monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32)
|
||||
assert env.DECNET_JWT_SECRET == "x" * 32
|
||||
|
||||
|
||||
def test_agent_cli_imports_without_jwt_secret(monkeypatch, tmp_path):
|
||||
"""Subprocess check: `decnet agent --help` must succeed with no
|
||||
DECNET_JWT_SECRET in the environment and no .env file in cwd."""
|
||||
import subprocess
|
||||
import pathlib
|
||||
clean_env = {
|
||||
k: v for k, v in os.environ.items()
|
||||
if not k.startswith("DECNET_") and not k.startswith("PYTEST")
|
||||
}
|
||||
clean_env["PATH"] = os.environ["PATH"]
|
||||
clean_env["HOME"] = str(tmp_path)
|
||||
repo = pathlib.Path(__file__).resolve().parent.parent.parent
|
||||
# binary = repo / ".venv" / "bin" / "decnet"
|
||||
binary = Path(sys.executable).parent / "decnet"
|
||||
result = subprocess.run(
|
||||
[str(binary), "agent", "--help"],
|
||||
cwd=str(tmp_path),
|
||||
env=clean_env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "worker agent" in result.stdout.lower()
|
||||
|
||||
|
||||
def test_unknown_attr_still_raises(monkeypatch):
|
||||
env = _reimport_env(monkeypatch)
|
||||
with pytest.raises(AttributeError):
|
||||
_ = env.DOES_NOT_EXIST
|
||||
67
tests/web/test_health_config_cache.py
Normal file
67
tests/web/test_health_config_cache.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
TTL-cache contract: under concurrent load, N callers collapse to 1 repo hit
|
||||
per TTL window. Tests use fake repo objects — no real DB.
|
||||
"""
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.web.router.health import api_get_health
|
||||
from decnet.web.router.config import api_get_config
|
||||
|
||||
|
||||
class _FakeRepo:
|
||||
def __init__(self):
|
||||
self.total_logs_calls = 0
|
||||
self.state_calls = 0
|
||||
|
||||
async def get_total_logs(self):
|
||||
self.total_logs_calls += 1
|
||||
return 0
|
||||
|
||||
async def get_state(self, name: str):
|
||||
self.state_calls += 1
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_cache_collapses_concurrent_calls():
|
||||
api_get_health._reset_db_cache()
|
||||
fake = _FakeRepo()
|
||||
with patch.object(api_get_health, "repo", fake):
|
||||
results = await asyncio.gather(*[api_get_health._check_database_cached() for _ in range(50)])
|
||||
assert all(r.status == "ok" for r in results)
|
||||
assert fake.total_logs_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_cache_expires_after_ttl(monkeypatch):
|
||||
api_get_health._reset_db_cache()
|
||||
monkeypatch.setattr(api_get_health, "_DB_CHECK_INTERVAL", 0.05)
|
||||
fake = _FakeRepo()
|
||||
with patch.object(api_get_health, "repo", fake):
|
||||
await api_get_health._check_database_cached()
|
||||
await asyncio.sleep(0.1)
|
||||
await api_get_health._check_database_cached()
|
||||
assert fake.total_logs_calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_state_cache_collapses_concurrent_calls():
|
||||
api_get_config._reset_state_cache()
|
||||
fake = _FakeRepo()
|
||||
with patch.object(api_get_config, "repo", fake):
|
||||
results = await asyncio.gather(*[api_get_config._get_state_cached("config_limits") for _ in range(30)])
|
||||
assert all(r == {"name": "config_limits"} for r in results)
|
||||
assert fake.state_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_state_cache_per_key():
|
||||
api_get_config._reset_state_cache()
|
||||
fake = _FakeRepo()
|
||||
with patch.object(api_get_config, "repo", fake):
|
||||
await api_get_config._get_state_cached("config_limits")
|
||||
await api_get_config._get_state_cached("config_globals")
|
||||
assert fake.state_calls == 2
|
||||
394
tests/web/test_ingester.py
Normal file
394
tests/web/test_ingester.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
Tests for decnet/web/ingester.py
|
||||
|
||||
Covers log_ingestion_worker and _extract_bounty with
|
||||
async tests using temporary files.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── _extract_bounty ───────────────────────────────────────────────────────────
|
||||
|
||||
class TestExtractBounty:
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_extraction(self):
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
log_data: dict = {
|
||||
"decky": "decky-01",
|
||||
"service": "ssh",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {"username": "admin", "password": "hunter2"},
|
||||
}
|
||||
await _extract_bounty(mock_repo, log_data)
|
||||
mock_repo.add_bounty.assert_awaited_once()
|
||||
bounty = mock_repo.add_bounty.call_args[0][0]
|
||||
assert bounty["bounty_type"] == "credential"
|
||||
assert bounty["payload"]["username"] == "admin"
|
||||
assert bounty["payload"]["password"] == "hunter2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_fields_skips(self):
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
await _extract_bounty(mock_repo, {"decky": "x"})
|
||||
mock_repo.add_bounty.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fields_not_dict_skips(self):
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
await _extract_bounty(mock_repo, {"fields": "not-a-dict"})
|
||||
mock_repo.add_bounty.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_password_skips(self):
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
await _extract_bounty(mock_repo, {"fields": {"username": "admin"}})
|
||||
mock_repo.add_bounty.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_username_skips(self):
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
await _extract_bounty(mock_repo, {"fields": {"password": "pass"}})
|
||||
mock_repo.add_bounty.assert_not_awaited()
|
||||
|
||||
|
||||
# ── log_ingestion_worker ──────────────────────────────────────────────────────
|
||||
|
||||
class TestLogIngestionWorker:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_env_var_returns_immediately(self):
|
||||
from decnet.web.ingester import log_ingestion_worker
|
||||
mock_repo = MagicMock()
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
# Remove DECNET_INGEST_LOG_FILE if set
|
||||
os.environ.pop("DECNET_INGEST_LOG_FILE", None)
|
||||
await log_ingestion_worker(mock_repo)
|
||||
# Should return immediately without error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_not_exists_waits(self, tmp_path):
|
||||
from decnet.web.ingester import log_ingestion_worker
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_log = AsyncMock()
|
||||
mock_repo.add_logs = AsyncMock()
|
||||
mock_repo.get_state = AsyncMock(return_value=None)
|
||||
mock_repo.set_state = AsyncMock()
|
||||
log_file = str(tmp_path / "nonexistent.log")
|
||||
_call_count: int = 0
|
||||
|
||||
async def fake_sleep(secs):
|
||||
nonlocal _call_count
|
||||
_call_count += 1
|
||||
if _call_count >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
|
||||
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await log_ingestion_worker(mock_repo)
|
||||
mock_repo.add_logs.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingests_json_lines(self, tmp_path):
|
||||
from decnet.web.ingester import log_ingestion_worker
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_log = AsyncMock()
|
||||
mock_repo.add_logs = AsyncMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
mock_repo.get_state = AsyncMock(return_value=None)
|
||||
mock_repo.set_state = AsyncMock()
|
||||
|
||||
log_file = str(tmp_path / "test.log")
|
||||
json_file = tmp_path / "test.json"
|
||||
json_file.write_text(
|
||||
json.dumps({"decky": "d1", "service": "ssh", "event_type": "auth",
|
||||
"attacker_ip": "1.2.3.4", "fields": {}, "raw_line": "x", "msg": ""}) + "\n"
|
||||
)
|
||||
|
||||
_call_count: int = 0
|
||||
|
||||
async def fake_sleep(secs):
|
||||
nonlocal _call_count
|
||||
_call_count += 1
|
||||
if _call_count >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
|
||||
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await log_ingestion_worker(mock_repo)
|
||||
|
||||
mock_repo.add_logs.assert_awaited_once()
|
||||
_batch = mock_repo.add_logs.call_args[0][0]
|
||||
assert len(_batch) == 1
|
||||
assert _batch[0]["attacker_ip"] == "1.2.3.4"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_json_decode_error(self, tmp_path):
|
||||
from decnet.web.ingester import log_ingestion_worker
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_log = AsyncMock()
|
||||
mock_repo.add_logs = AsyncMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
mock_repo.get_state = AsyncMock(return_value=None)
|
||||
mock_repo.set_state = AsyncMock()
|
||||
|
||||
log_file = str(tmp_path / "test.log")
|
||||
json_file = tmp_path / "test.json"
|
||||
json_file.write_text("not valid json\n")
|
||||
|
||||
_call_count: int = 0
|
||||
|
||||
async def fake_sleep(secs):
|
||||
nonlocal _call_count
|
||||
_call_count += 1
|
||||
if _call_count >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
|
||||
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await log_ingestion_worker(mock_repo)
|
||||
|
||||
mock_repo.add_logs.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_truncation_resets_position(self, tmp_path):
|
||||
from decnet.web.ingester import log_ingestion_worker
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_log = AsyncMock()
|
||||
mock_repo.add_logs = AsyncMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
mock_repo.get_state = AsyncMock(return_value=None)
|
||||
mock_repo.set_state = AsyncMock()
|
||||
|
||||
log_file = str(tmp_path / "test.log")
|
||||
json_file = tmp_path / "test.json"
|
||||
|
||||
_line: str = json.dumps({"decky": "d1", "service": "ssh", "event_type": "auth",
|
||||
"attacker_ip": "1.2.3.4", "fields": {}, "raw_line": "x", "msg": ""})
|
||||
# Write 2 lines, then truncate to 1
|
||||
json_file.write_text(_line + "\n" + _line + "\n")
|
||||
|
||||
_call_count: int = 0
|
||||
|
||||
async def fake_sleep(secs):
|
||||
nonlocal _call_count
|
||||
_call_count += 1
|
||||
if _call_count == 2:
|
||||
# Simulate truncation
|
||||
json_file.write_text(_line + "\n")
|
||||
if _call_count >= 4:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
|
||||
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await log_ingestion_worker(mock_repo)
|
||||
|
||||
# Should have ingested lines from original + after truncation
|
||||
_total = sum(len(call.args[0]) for call in mock_repo.add_logs.call_args_list)
|
||||
assert _total >= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_line_not_processed(self, tmp_path):
|
||||
from decnet.web.ingester import log_ingestion_worker
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_log = AsyncMock()
|
||||
mock_repo.add_logs = AsyncMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
mock_repo.get_state = AsyncMock(return_value=None)
|
||||
mock_repo.set_state = AsyncMock()
|
||||
|
||||
log_file = str(tmp_path / "test.log")
|
||||
json_file = tmp_path / "test.json"
|
||||
# Write a partial line (no newline at end)
|
||||
json_file.write_text('{"partial": true')
|
||||
|
||||
_call_count: int = 0
|
||||
|
||||
async def fake_sleep(secs):
|
||||
nonlocal _call_count
|
||||
_call_count += 1
|
||||
if _call_count >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
|
||||
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await log_ingestion_worker(mock_repo)
|
||||
|
||||
mock_repo.add_logs.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_position_restored_skips_already_seen_lines(self, tmp_path):
|
||||
"""Worker resumes from saved position and skips already-ingested content."""
|
||||
from decnet.web.ingester import log_ingestion_worker
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_log = AsyncMock()
|
||||
mock_repo.add_logs = AsyncMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
mock_repo.set_state = AsyncMock()
|
||||
|
||||
log_file = str(tmp_path / "test.log")
|
||||
json_file = tmp_path / "test.json"
|
||||
|
||||
line_old = json.dumps({"decky": "d1", "service": "ssh", "event_type": "auth",
|
||||
"attacker_ip": "1.1.1.1", "fields": {}, "raw_line": "x", "msg": ""}) + "\n"
|
||||
line_new = json.dumps({"decky": "d2", "service": "ftp", "event_type": "auth",
|
||||
"attacker_ip": "2.2.2.2", "fields": {}, "raw_line": "y", "msg": ""}) + "\n"
|
||||
|
||||
json_file.write_text(line_old + line_new)
|
||||
|
||||
# Saved position points to end of first line — only line_new should be ingested
|
||||
saved_position = len(line_old.encode("utf-8"))
|
||||
mock_repo.get_state = AsyncMock(return_value={"position": saved_position})
|
||||
|
||||
_call_count: int = 0
|
||||
|
||||
async def fake_sleep(secs):
|
||||
nonlocal _call_count
|
||||
_call_count += 1
|
||||
if _call_count >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
|
||||
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await log_ingestion_worker(mock_repo)
|
||||
|
||||
_rows = [r for call in mock_repo.add_logs.call_args_list for r in call.args[0]]
|
||||
assert len(_rows) == 1
|
||||
assert _rows[0]["attacker_ip"] == "2.2.2.2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_state_called_with_position_after_batch(self, tmp_path):
|
||||
"""set_state is called with the updated byte position after processing lines."""
|
||||
from decnet.web.ingester import log_ingestion_worker, _INGEST_STATE_KEY
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_log = AsyncMock()
|
||||
mock_repo.add_logs = AsyncMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
mock_repo.get_state = AsyncMock(return_value=None)
|
||||
mock_repo.set_state = AsyncMock()
|
||||
|
||||
log_file = str(tmp_path / "test.log")
|
||||
json_file = tmp_path / "test.json"
|
||||
line = json.dumps({"decky": "d1", "service": "ssh", "event_type": "auth",
|
||||
"attacker_ip": "1.1.1.1", "fields": {}, "raw_line": "x", "msg": ""}) + "\n"
|
||||
json_file.write_text(line)
|
||||
|
||||
_call_count: int = 0
|
||||
|
||||
async def fake_sleep(secs):
|
||||
nonlocal _call_count
|
||||
_call_count += 1
|
||||
if _call_count >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
|
||||
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await log_ingestion_worker(mock_repo)
|
||||
|
||||
set_state_calls = mock_repo.set_state.call_args_list
|
||||
position_calls = [c for c in set_state_calls if c[0][0] == _INGEST_STATE_KEY]
|
||||
assert position_calls, "set_state never called with ingest position key"
|
||||
saved_pos = position_calls[-1][0][1]["position"]
|
||||
assert saved_pos == len(line.encode("utf-8"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batches_many_lines_into_few_commits(self, tmp_path):
|
||||
"""250 lines with BATCH_SIZE=100 should flush in a handful of calls."""
|
||||
from decnet.web.ingester import log_ingestion_worker
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_log = AsyncMock()
|
||||
mock_repo.add_logs = AsyncMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
mock_repo.get_state = AsyncMock(return_value=None)
|
||||
mock_repo.set_state = AsyncMock()
|
||||
|
||||
log_file = str(tmp_path / "test.log")
|
||||
json_file = tmp_path / "test.json"
|
||||
_lines = "".join(
|
||||
json.dumps({
|
||||
"decky": f"d{i}", "service": "ssh", "event_type": "auth",
|
||||
"attacker_ip": f"10.0.0.{i % 256}", "fields": {}, "raw_line": "x", "msg": ""
|
||||
}) + "\n"
|
||||
for i in range(250)
|
||||
)
|
||||
json_file.write_text(_lines)
|
||||
|
||||
_call_count: int = 0
|
||||
|
||||
async def fake_sleep(secs):
|
||||
nonlocal _call_count
|
||||
_call_count += 1
|
||||
if _call_count >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
|
||||
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await log_ingestion_worker(mock_repo)
|
||||
|
||||
# 250 lines, batch=100 → 2 size-triggered flushes + 1 remainder flush.
|
||||
# Asserting <= 5 leaves headroom for time-triggered flushes on slow CI.
|
||||
assert mock_repo.add_logs.await_count <= 5
|
||||
_rows = [r for call in mock_repo.add_logs.call_args_list for r in call.args[0]]
|
||||
assert len(_rows) == 250
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncation_resets_and_saves_zero_position(self, tmp_path):
|
||||
"""On file truncation, set_state is called with position=0."""
|
||||
from decnet.web.ingester import log_ingestion_worker, _INGEST_STATE_KEY
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_log = AsyncMock()
|
||||
mock_repo.add_logs = AsyncMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
mock_repo.set_state = AsyncMock()
|
||||
|
||||
log_file = str(tmp_path / "test.log")
|
||||
json_file = tmp_path / "test.json"
|
||||
|
||||
line = json.dumps({"decky": "d1", "service": "ssh", "event_type": "auth",
|
||||
"attacker_ip": "1.1.1.1", "fields": {}, "raw_line": "x", "msg": ""}) + "\n"
|
||||
# Pretend the saved position is past the end (simulates prior larger file)
|
||||
big_position = len(line.encode("utf-8")) * 10
|
||||
mock_repo.get_state = AsyncMock(return_value={"position": big_position})
|
||||
|
||||
json_file.write_text(line) # file is smaller than saved position → truncation
|
||||
|
||||
_call_count: int = 0
|
||||
|
||||
async def fake_sleep(secs):
|
||||
nonlocal _call_count
|
||||
_call_count += 1
|
||||
if _call_count >= 2:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}):
|
||||
with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await log_ingestion_worker(mock_repo)
|
||||
|
||||
reset_calls = [
|
||||
c for c in mock_repo.set_state.call_args_list
|
||||
if c[0][0] == _INGEST_STATE_KEY and c[0][1] == {"position": 0}
|
||||
]
|
||||
assert reset_calls, "set_state not called with position=0 after truncation"
|
||||
110
tests/web/test_router_cache.py
Normal file
110
tests/web/test_router_cache.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
TTL-cache contract for /stats, /logs total count, and /attackers total count.
|
||||
|
||||
Under concurrent load N callers should collapse to 1 repo hit per TTL
|
||||
window. Tests patch the repo — no real DB.
|
||||
"""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.web.router.stats import api_get_stats
|
||||
from decnet.web.router.logs import api_get_logs
|
||||
from decnet.web.router.attackers import api_get_attackers
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_router_caches():
|
||||
api_get_stats._reset_stats_cache()
|
||||
api_get_logs._reset_total_cache()
|
||||
api_get_attackers._reset_total_cache()
|
||||
yield
|
||||
api_get_stats._reset_stats_cache()
|
||||
api_get_logs._reset_total_cache()
|
||||
api_get_attackers._reset_total_cache()
|
||||
|
||||
|
||||
# ── /stats whole-response cache ──────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats_cache_collapses_concurrent_calls():
|
||||
api_get_stats._reset_stats_cache()
|
||||
payload = {"total_logs": 42, "unique_attackers": 7, "active_deckies": 3, "deployed_deckies": 3}
|
||||
with patch.object(api_get_stats, "repo") as mock_repo:
|
||||
mock_repo.get_stats_summary = AsyncMock(return_value=payload)
|
||||
results = await asyncio.gather(*[api_get_stats._get_stats_cached() for _ in range(50)])
|
||||
assert all(r == payload for r in results)
|
||||
assert mock_repo.get_stats_summary.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats_cache_expires_after_ttl(monkeypatch):
|
||||
api_get_stats._reset_stats_cache()
|
||||
clock = {"t": 0.0}
|
||||
monkeypatch.setattr(api_get_stats.time, "monotonic", lambda: clock["t"])
|
||||
with patch.object(api_get_stats, "repo") as mock_repo:
|
||||
mock_repo.get_stats_summary = AsyncMock(return_value={"total_logs": 1, "unique_attackers": 0, "active_deckies": 0, "deployed_deckies": 0})
|
||||
await api_get_stats._get_stats_cached()
|
||||
clock["t"] = 100.0 # past TTL
|
||||
await api_get_stats._get_stats_cached()
|
||||
assert mock_repo.get_stats_summary.await_count == 2
|
||||
|
||||
|
||||
# ── /logs total-count cache ──────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_total_cache_collapses_concurrent_calls():
|
||||
api_get_logs._reset_total_cache()
|
||||
with patch.object(api_get_logs, "repo") as mock_repo:
|
||||
mock_repo.get_total_logs = AsyncMock(return_value=1234)
|
||||
results = await asyncio.gather(*[api_get_logs._get_total_logs_cached() for _ in range(50)])
|
||||
assert all(r == 1234 for r in results)
|
||||
assert mock_repo.get_total_logs.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_filtered_count_bypasses_cache():
|
||||
"""When a filter is provided, the endpoint must hit repo every time."""
|
||||
api_get_logs._reset_total_cache()
|
||||
with patch.object(api_get_logs, "repo") as mock_repo:
|
||||
mock_repo.get_logs = AsyncMock(return_value=[])
|
||||
mock_repo.get_total_logs = AsyncMock(return_value=0)
|
||||
for _ in range(3):
|
||||
await api_get_logs.get_logs(
|
||||
limit=50, offset=0, search="needle", start_time=None, end_time=None,
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
# 3 filtered calls → 3 repo hits, all with search=needle
|
||||
assert mock_repo.get_total_logs.await_count == 3
|
||||
for call in mock_repo.get_total_logs.await_args_list:
|
||||
assert call.kwargs["search"] == "needle"
|
||||
|
||||
|
||||
# ── /attackers total-count cache ─────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attackers_total_cache_collapses_concurrent_calls():
|
||||
api_get_attackers._reset_total_cache()
|
||||
with patch.object(api_get_attackers, "repo") as mock_repo:
|
||||
mock_repo.get_total_attackers = AsyncMock(return_value=99)
|
||||
results = await asyncio.gather(*[api_get_attackers._get_total_attackers_cached() for _ in range(50)])
|
||||
assert all(r == 99 for r in results)
|
||||
assert mock_repo.get_total_attackers.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attackers_filtered_count_bypasses_cache():
|
||||
api_get_attackers._reset_total_cache()
|
||||
with patch.object(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={})
|
||||
for _ in range(3):
|
||||
await api_get_attackers.get_attackers(
|
||||
limit=50, offset=0, search="10.", sort_by="recent", service=None,
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
assert mock_repo.get_total_attackers.await_count == 3
|
||||
for call in mock_repo.get_total_attackers.await_args_list:
|
||||
assert call.kwargs["search"] == "10."
|
||||
156
tests/web/test_web_api.py
Normal file
156
tests/web/test_web_api.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Tests for decnet/web/api.py lifespan and decnet/web/dependencies.py auth helpers.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.web.auth import create_access_token
|
||||
|
||||
|
||||
# ── get_current_user ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestGetCurrentUser:
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_token(self):
|
||||
from decnet.web.dependencies import get_current_user
|
||||
token = create_access_token({"uuid": "test-uuid-123"})
|
||||
request = MagicMock()
|
||||
request.headers = {"Authorization": f"Bearer {token}"}
|
||||
result = await get_current_user(request)
|
||||
assert result == "test-uuid-123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_auth_header(self):
|
||||
from fastapi import HTTPException
|
||||
from decnet.web.dependencies import get_current_user
|
||||
request = MagicMock()
|
||||
request.headers = {}
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_user(request)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_jwt(self):
|
||||
from fastapi import HTTPException
|
||||
from decnet.web.dependencies import get_current_user
|
||||
request = MagicMock()
|
||||
request.headers = {"Authorization": "Bearer invalid-token"}
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_user(request)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_uuid_in_payload(self):
|
||||
from fastapi import HTTPException
|
||||
from decnet.web.dependencies import get_current_user
|
||||
token = create_access_token({"sub": "no-uuid-field"})
|
||||
request = MagicMock()
|
||||
request.headers = {"Authorization": f"Bearer {token}"}
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_user(request)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bearer_prefix_required(self):
|
||||
from fastapi import HTTPException
|
||||
from decnet.web.dependencies import get_current_user
|
||||
token = create_access_token({"uuid": "test-uuid"})
|
||||
request = MagicMock()
|
||||
request.headers = {"Authorization": f"Token {token}"}
|
||||
with pytest.raises(HTTPException):
|
||||
await get_current_user(request)
|
||||
|
||||
|
||||
# ── get_stream_user ───────────────────────────────────────────────────────────
|
||||
|
||||
class TestGetStreamUser:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bearer_header(self):
|
||||
from decnet.web.dependencies import get_stream_user
|
||||
token = create_access_token({"uuid": "stream-uuid"})
|
||||
request = MagicMock()
|
||||
request.headers = {"Authorization": f"Bearer {token}"}
|
||||
result = await get_stream_user(request, token=None)
|
||||
assert result == "stream-uuid"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_param_fallback(self):
|
||||
from decnet.web.dependencies import get_stream_user
|
||||
token = create_access_token({"uuid": "query-uuid"})
|
||||
request = MagicMock()
|
||||
request.headers = {}
|
||||
result = await get_stream_user(request, token=token)
|
||||
assert result == "query-uuid"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_token_raises(self):
|
||||
from fastapi import HTTPException
|
||||
from decnet.web.dependencies import get_stream_user
|
||||
request = MagicMock()
|
||||
request.headers = {}
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_stream_user(request, token=None)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_token_raises(self):
|
||||
from fastapi import HTTPException
|
||||
from decnet.web.dependencies import get_stream_user
|
||||
request = MagicMock()
|
||||
request.headers = {}
|
||||
with pytest.raises(HTTPException):
|
||||
await get_stream_user(request, token="bad-token")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_uuid_raises(self):
|
||||
from fastapi import HTTPException
|
||||
from decnet.web.dependencies import get_stream_user
|
||||
token = create_access_token({"sub": "no-uuid"})
|
||||
request = MagicMock()
|
||||
request.headers = {"Authorization": f"Bearer {token}"}
|
||||
with pytest.raises(HTTPException):
|
||||
await get_stream_user(request, token=None)
|
||||
|
||||
|
||||
# ── web/api.py lifespan ──────────────────────────────────────────────────────
|
||||
|
||||
class TestLifespan:
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_startup_and_shutdown(self):
|
||||
from decnet.web.api import lifespan
|
||||
mock_app = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.initialize = AsyncMock()
|
||||
|
||||
with patch("decnet.web.api.repo", mock_repo):
|
||||
with patch("decnet.web.api.log_ingestion_worker", return_value=asyncio.sleep(0)):
|
||||
with patch("decnet.web.api.log_collector_worker", return_value=asyncio.sleep(0)):
|
||||
with patch("decnet.web.api.attacker_profile_worker", return_value=asyncio.sleep(0)):
|
||||
async with lifespan(mock_app):
|
||||
mock_repo.initialize.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_db_retry(self):
|
||||
from decnet.web.api import lifespan
|
||||
mock_app = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
_call_count: int = 0
|
||||
|
||||
async def _failing_init():
|
||||
nonlocal _call_count
|
||||
_call_count += 1
|
||||
if _call_count < 3:
|
||||
raise Exception("DB locked")
|
||||
|
||||
mock_repo.initialize = _failing_init
|
||||
|
||||
with patch("decnet.web.api.repo", mock_repo):
|
||||
with patch("decnet.web.api.asyncio.sleep", new_callable=AsyncMock):
|
||||
with patch("decnet.web.api.log_ingestion_worker", return_value=asyncio.sleep(0)):
|
||||
with patch("decnet.web.api.log_collector_worker", return_value=asyncio.sleep(0)):
|
||||
with patch("decnet.web.api.attacker_profile_worker", return_value=asyncio.sleep(0)):
|
||||
async with lifespan(mock_app):
|
||||
assert _call_count == 3
|
||||
Reference in New Issue
Block a user