Compare commits
8 Commits
c3c1cd2fa6
...
0f63820ee6
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f63820ee6 | |||
| fdc404760f | |||
| 95190946e0 | |||
| 1692df7360 | |||
| aac39e818e | |||
| ff38d58508 | |||
| f78104e1c8 | |||
| 99be4e64ad |
@@ -21,7 +21,8 @@
|
||||
"Bash(docker image:*)",
|
||||
"Read(//home/anti/Tools/cowrie/src/cowrie/data/txtcmds/**)",
|
||||
"Read(//home/anti/Tools/cowrie/src/cowrie/data/txtcmds/bin/**)",
|
||||
"mcp__plugin_context-mode_context-mode__ctx_index"
|
||||
"mcp__plugin_context-mode_context-mode__ctx_index",
|
||||
"Bash(ls:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,18 +56,39 @@ jobs:
|
||||
- run: pip install -e .[dev]
|
||||
- run: pip-audit --skip-editable
|
||||
|
||||
merge-to-testing:
|
||||
name: Merge dev → testing
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test, bandit, pip-audit]
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.DECNET_PR_TOKEN }}
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "DECNET CI"
|
||||
git config user.email "ci@decnet.local"
|
||||
- name: Merge dev into testing
|
||||
run: |
|
||||
git fetch origin testing
|
||||
git checkout testing
|
||||
git merge origin/dev --no-ff -m "ci: auto-merge dev → testing"
|
||||
git push origin testing
|
||||
|
||||
open-pr:
|
||||
name: Open PR to main
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test, bandit, pip-audit]
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
if: github.ref == 'refs/heads/testing'
|
||||
steps:
|
||||
- name: Open PR via Gitea API
|
||||
run: |
|
||||
echo "--- Checking for existing open PRs ---"
|
||||
LIST_RESPONSE=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.DECNET_PR_TOKEN }}" \
|
||||
"https://git.resacachile.cl/api/v1/repos/anti/DECNET/pulls?state=open&head=anti:dev&base=main&limit=5")
|
||||
"https://git.resacachile.cl/api/v1/repos/anti/DECNET/pulls?state=open&head=anti:testing&base=main&limit=5")
|
||||
echo "$LIST_RESPONSE"
|
||||
EXISTING=$(echo "$LIST_RESPONSE" | python3 -c "import sys, json; print(len(json.load(sys.stdin)))")
|
||||
echo "Open PRs found: $EXISTING"
|
||||
@@ -80,10 +101,10 @@ jobs:
|
||||
-H "Authorization: token ${{ secrets.DECNET_PR_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Auto PR: dev → main",
|
||||
"head": "dev",
|
||||
"title": "Auto PR: testing → main",
|
||||
"head": "testing",
|
||||
"base": "main",
|
||||
"body": "All CI and security checks passed. Review and merge when ready."
|
||||
"body": "All CI and security checks passed on both dev and testing. Review and merge when ready."
|
||||
}' \
|
||||
"https://git.resacachile.cl/api/v1/repos/anti/DECNET/pulls")
|
||||
echo "$CREATE_RESPONSE"
|
||||
|
||||
@@ -30,5 +30,28 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: pip install -e .
|
||||
- run: pip install -e .[dev]
|
||||
- run: pytest tests/ -v --tb=short
|
||||
|
||||
bandit:
|
||||
name: SAST (bandit)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- run: pip install bandit
|
||||
- run: bandit -r decnet/ -ll -x decnet/services/registry.py
|
||||
|
||||
pip-audit:
|
||||
name: Dependency audit (pip-audit)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- run: pip install pip-audit
|
||||
- run: pip install -e .[dev]
|
||||
- run: pip-audit --skip-editable
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -20,3 +20,5 @@ windows1
|
||||
decnet.json
|
||||
.env
|
||||
.env.local
|
||||
.coverage
|
||||
.hypothesis/
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# arche-test.ini
|
||||
# OS fingerprint smoke-test fleet.
|
||||
#
|
||||
# One group per OS family, each spinning up 2 deckies.
|
||||
# Deploy with:
|
||||
# sudo .venv/bin/decnet deploy --config arche-test.ini --dry-run
|
||||
# sudo .venv/bin/decnet deploy --config arche-test.ini --interface eth0
|
||||
#
|
||||
# After deploy, verify with:
|
||||
# sudo nmap -O --osscan-guess <ip>
|
||||
# sudo p0f -i <iface> -p
|
||||
# ---- Linux (TTL 64, timestamps on, ECN offer) ----
|
||||
[os-linux]
|
||||
nmap_os=linux
|
||||
services=ssh,http
|
||||
amount=2
|
||||
|
||||
# ---- Windows (TTL 128, timestamps off, no ECN) ----
|
||||
[os-windows]
|
||||
nmap_os=windows
|
||||
services=smb,rdp
|
||||
amount=2
|
||||
|
||||
# ---- BSD (TTL 64, timestamps on, no ECN) ----
|
||||
[os-bsd]
|
||||
nmap_os=bsd
|
||||
services=ssh,http
|
||||
amount=2
|
||||
|
||||
# ---- Embedded (TTL 255, timestamps off, no SACK, no window scaling) ----
|
||||
[os-embedded]
|
||||
nmap_os=embedded
|
||||
services=snmp
|
||||
amount=2
|
||||
|
||||
# ---- Cisco (TTL 255, timestamps off, no SACK, ip_no_pmtu_disc on) ----
|
||||
[os-cisco]
|
||||
nmap_os=cisco
|
||||
services=snmp
|
||||
amount=2
|
||||
@@ -24,13 +24,11 @@ from decnet.env import (
|
||||
)
|
||||
from decnet.archetypes import Archetype, all_archetypes, get_archetype
|
||||
from decnet.config import (
|
||||
DeckyConfig,
|
||||
DecnetConfig,
|
||||
random_hostname,
|
||||
)
|
||||
from decnet.distros import all_distros, get_distro
|
||||
from decnet.fleet import all_service_names, build_deckies, build_deckies_from_ini
|
||||
from decnet.ini_loader import IniConfig, load_ini
|
||||
from decnet.ini_loader import load_ini
|
||||
from decnet.network import detect_interface, detect_subnet, allocate_ips, get_host_ip
|
||||
from decnet.services.registry import all_services
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from decnet.archetypes import get_archetype
|
||||
from decnet.fleet import all_service_names
|
||||
from decnet.composer import write_compose
|
||||
from decnet.config import DeckyConfig, load_state, save_state
|
||||
from decnet.engine import COMPOSE_FILE, _compose_with_retry
|
||||
from decnet.engine import _compose_with_retry
|
||||
|
||||
import subprocess # nosec B404
|
||||
|
||||
|
||||
@@ -33,25 +33,20 @@ class SQLiteRepository(BaseRepository):
|
||||
from decnet.web.db.sqlite.database import get_sync_engine
|
||||
engine = get_sync_engine(self.db_path)
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text("SELECT uuid FROM users WHERE username = :u"),
|
||||
{"u": DECNET_ADMIN_USER},
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT OR IGNORE INTO users (uuid, username, password_hash, role, must_change_password) "
|
||||
"VALUES (:uuid, :u, :p, :r, :m)"
|
||||
),
|
||||
{
|
||||
"uuid": str(uuid.uuid4()),
|
||||
"u": DECNET_ADMIN_USER,
|
||||
"p": get_password_hash(DECNET_ADMIN_PASSWORD),
|
||||
"r": "admin",
|
||||
"m": 1,
|
||||
},
|
||||
)
|
||||
if not result.fetchone():
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO users (uuid, username, password_hash, role, must_change_password) "
|
||||
"VALUES (:uuid, :u, :p, :r, :m)"
|
||||
),
|
||||
{
|
||||
"uuid": str(uuid.uuid4()),
|
||||
"u": DECNET_ADMIN_USER,
|
||||
"p": get_password_hash(DECNET_ADMIN_PASSWORD),
|
||||
"r": "admin",
|
||||
"m": 1,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
conn.commit()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Async warm-up / verification."""
|
||||
|
||||
107
development/COVERAGE.md
Normal file
107
development/COVERAGE.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# DECNET Test Coverage Report
|
||||
|
||||
> **Last Updated:** 2026-04-12
|
||||
> **Total Coverage:** 93% ✅
|
||||
> **Total Tests:** 1074 Passed ✅
|
||||
|
||||
## 📊 Full Coverage Table
|
||||
|
||||
```text
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------------------------------
|
||||
decnet/__init__.py 0 0 100%
|
||||
decnet/archetypes.py 21 0 100%
|
||||
decnet/cli.py 265 43 84% 62-63, 136, 138, 146-149, 163-165, 179-180, 198-199, 223-223, 251-252, 255-260, 282-283, 385-386, 390-393, 398, 400-401, 409-410, 418-419, 458-461
|
||||
decnet/collector/__init__.py 2 0 100%
|
||||
decnet/collector/worker.py 110 3 97% 196-198
|
||||
decnet/composer.py 36 3 92% 110-112
|
||||
decnet/config.py 38 0 100%
|
||||
decnet/correlation/__init__.py 4 0 100%
|
||||
decnet/correlation/engine.py 62 0 100%
|
||||
decnet/correlation/graph.py 37 0 100%
|
||||
decnet/correlation/parser.py 47 2 96% 98-99
|
||||
decnet/custom_service.py 17 0 100%
|
||||
decnet/distros.py 26 1 96% 110
|
||||
decnet/engine/__init__.py 2 0 100%
|
||||
decnet/engine/deployer.py 147 8 95% 42, 45, 177-182
|
||||
decnet/env.py 38 7 82% 17-18, 20, 29, 37-42
|
||||
decnet/fleet.py 83 1 99% 136
|
||||
decnet/ini_loader.py 111 5 95% 158-161, 205
|
||||
decnet/logging/__init__.py 0 0 100%
|
||||
decnet/logging/file_handler.py 30 0 100%
|
||||
decnet/logging/forwarder.py 13 0 100%
|
||||
decnet/logging/syslog_formatter.py 34 0 100%
|
||||
decnet/mutator/__init__.py 2 0 100%
|
||||
decnet/mutator/engine.py 80 10 88% 43, 50-51, 116-122
|
||||
decnet/network.py 106 0 100%
|
||||
decnet/os_fingerprint.py 8 0 100%
|
||||
decnet/services/__init__.py 0 0 100%
|
||||
decnet/services/base.py 7 1 86% 42
|
||||
decnet/services/conpot.py 13 0 100%
|
||||
decnet/services/docker_api.py 14 0 100%
|
||||
decnet/services/elasticsearch.py 14 0 100%
|
||||
decnet/services/ftp.py 14 0 100%
|
||||
decnet/services/http.py 31 3 90% 46-48
|
||||
decnet/services/imap.py 14 0 100%
|
||||
decnet/services/k8s.py 14 0 100%
|
||||
decnet/services/ldap.py 14 0 100%
|
||||
decnet/services/llmnr.py 14 0 100%
|
||||
decnet/services/mongodb.py 14 0 100%
|
||||
decnet/services/mqtt.py 14 0 100%
|
||||
decnet/services/mssql.py 14 0 100%
|
||||
decnet/services/mysql.py 17 0 100%
|
||||
decnet/services/pop3.py 14 0 100%
|
||||
decnet/services/postgres.py 14 0 100%
|
||||
decnet/services/rdp.py 14 0 100%
|
||||
decnet/services/redis.py 19 0 100%
|
||||
decnet/services/registry.py 31 3 90% 38-39, 45
|
||||
decnet/services/sip.py 14 0 100%
|
||||
decnet/services/smb.py 14 0 100%
|
||||
decnet/services/smtp.py 19 0 100%
|
||||
decnet/services/smtp_relay.py 19 0 100%
|
||||
decnet/services/snmp.py 14 0 100%
|
||||
decnet/services/ssh.py 15 0 100%
|
||||
decnet/services/telnet.py 15 1 93% 36
|
||||
decnet/services/tftp.py 14 0 100%
|
||||
decnet/services/vnc.py 14 0 100%
|
||||
decnet/web/api.py 39 2 95% 32, 44
|
||||
decnet/web/auth.py 23 0 100%
|
||||
decnet/web/db/models.py 41 0 100%
|
||||
decnet/web/db/repository.py 42 0 100%
|
||||
decnet/web/db/sqlite/database.py 21 4 81% 12, 29-33
|
||||
decnet/web/db/sqlite/repository.py 168 20 88% 53-54, 58-74, 81, 87-88, 112-113, 304, 306-307, 339-340
|
||||
decnet/web/dependencies.py 39 0 100%
|
||||
decnet/web/ingester.py 55 2 96% 66-67
|
||||
decnet/web/router/__init__.py 24 0 100%
|
||||
decnet/web/router/auth/api_change_pass.py 14 0 100%
|
||||
decnet/web/router/auth/api_login.py 15 0 100%
|
||||
decnet/web/router/bounty/api_get_bounties.py 10 0 100%
|
||||
decnet/web/router/fleet/api_deploy_deckies.py 50 38 24% 18-79
|
||||
decnet/web/router/fleet/api_get_deckies.py 7 0 100%
|
||||
decnet/web/router/fleet/api_mutate_decky.py 10 0 100%
|
||||
decnet/web/router/fleet/api_mutate_interval.py 17 0 100%
|
||||
decnet/web/router/logs/api_get_histogram.py 7 1 86% 19
|
||||
decnet/web/router/logs/api_get_logs.py 11 0 100%
|
||||
decnet/web/router/stats/api_get_stats.py 8 0 100%
|
||||
decnet/web/router/stream/api_stream_events.py 44 21 52% 36-68, 70
|
||||
------------------------------------------------------------------------------
|
||||
TOTAL 2402 179 93%
|
||||
```
|
||||
|
||||
## 📋 Future Coverage Plan (Missing Tests)
|
||||
|
||||
### 🔴 High Priority: `api_deploy_deckies.py` (24%)
|
||||
- **Problem:** Requires live Docker/MACVLAN orchestration.
|
||||
- **Plan:**
|
||||
- Implement a mock engine specifically for the API route test that validates the `config` object without calling Docker.
|
||||
- Integration testing using **Docker-in-Docker (DinD)** once CI infrastructure is ready.
|
||||
|
||||
### 🟡 Medium Priority: `api_stream_events.py` (52%)
|
||||
- **Problem:** Infinite event loop causes test hangs.
|
||||
- **Plan:**
|
||||
- Test frame headers/auth (Done).
|
||||
- Refactor generator to yield a fixed test set or use a loop-breaker for testing.
|
||||
|
||||
### 🟢 Low Priority: Misc. Service Logic
|
||||
- **Modules:** `services/http.py` (90%), `services/telnet.py` (93%), `distros.py` (96%).
|
||||
- **Plan:** Add edge-case unit tests for custom service configurations and invalid distro slugs.
|
||||
@@ -117,6 +117,11 @@ Bait emails are hardcoded. A stub env var `IMAP_EMAIL_SEED` is read but currentl
|
||||
The bait store and honeypot files are hardcoded. A dynamic injection framework should be created to populate this payload across different honeypots.
|
||||
**Status:** Deferred — out of current scope.
|
||||
|
||||
### DEBT-028 — Test coverage for `api_deploy_deckies.py`
|
||||
**File:** `decnet/web/router/fleet/api_deploy_deckies.py` (24% coverage)
|
||||
The deploy endpoint exercises Docker Compose orchestration via `decnet.engine.deploy`, which creates MACVLAN/IPvlan networks and runs `docker compose up`. Meaningful tests require mocking the entire Docker SDK + subprocess layer, coupling tightly to implementation details.
|
||||
**Status:** Deferred — test after Docker-in-Docker CI is available.
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Low
|
||||
@@ -170,6 +175,7 @@ The bait store and honeypot files are hardcoded. A dynamic injection framework s
|
||||
| ~~DEBT-025~~ | ✅ | Build | resolved |
|
||||
| DEBT-026 | 🟡 Medium | Features | deferred (out of scope) |
|
||||
| DEBT-027 | 🟡 Medium | Features | deferred (out of scope) |
|
||||
| DEBT-028 | 🟡 Medium | Testing | deferred (needs DinD CI) |
|
||||
|
||||
**Remaining open:** DEBT-011 (Alembic), DEBT-023 (image pinning), DEBT-026 (modular mailboxes), DEBT-027 (Dynamic bait store)
|
||||
**Estimated remaining effort:** ~10 hours
|
||||
**Remaining open:** DEBT-011 (Alembic), DEBT-023 (image pinning), DEBT-026 (modular mailboxes), DEBT-027 (Dynamic bait store), DEBT-028 (deploy endpoint tests)
|
||||
**Estimated remaining effort:** ~12 hours
|
||||
@@ -8,7 +8,7 @@
|
||||
- [ ] **Telnet (Cowrie)** — Realistic banner and command emulation.
|
||||
- [ ] **RDP** — Realistic NLA authentication and screen capture (where possible).
|
||||
- [ ] **VNC** — Realistic RFB protocol handshake and authentication.
|
||||
- [ ] **Real SSH** — Pass-through or high-interaction proxying.
|
||||
- [x] **Real SSH** — High-interaction sshd with shell logging.
|
||||
|
||||
### Databases
|
||||
- [ ] **MySQL** — Support for common SQL queries and realistic schema.
|
||||
@@ -22,6 +22,7 @@
|
||||
- [x] **HTTP** — Flexible templates (WordPress, phpMyAdmin, etc.) with logging.
|
||||
- [ ] **Docker API** — Realistic responses for `docker version` and `docker ps`.
|
||||
- [ ] **Kubernetes (K8s)** — Mocked kubectl responses and basic API exploration.
|
||||
- [x] **LLMNR** — Realistic local name resolution responses via responder-style emulation.
|
||||
|
||||
### File Transfer & Storage
|
||||
- [ ] **SMB** — Realistic share discovery and basic file browsing.
|
||||
@@ -38,7 +39,6 @@
|
||||
- [x] **MQTT** — Basic topic subscription and publishing support.
|
||||
- [x] **SNMP** — Realistic MIB responses for common OIDs.
|
||||
- [ ] **SIP** — Basic VoIP protocol handshake and registration.
|
||||
- [ ] **LLMNR** — Realistic local name resolution responses.
|
||||
- [x] **Conpot** — SCADA/ICS protocol emulation (Modbus, etc.).
|
||||
|
||||
---
|
||||
@@ -49,7 +49,7 @@
|
||||
- [ ] **Canary tokens** — Embed fake AWS keys and honeydocs into decky filesystems.
|
||||
- [ ] **Tarpit mode** — Slow down attackers by drip-feeding bytes or delaying responses.
|
||||
- [x] **Dynamic decky mutation** — Rotate exposed services or OS fingerprints over time.
|
||||
- [ ] **Credential harvesting DB** — Centralized database for all username/password attempts.
|
||||
- [x] **Credential harvesting DB** — Centralized database for all username/password attempts.
|
||||
- [ ] **Session recording** — Full capture for SSH/Telnet sessions.
|
||||
- [ ] **Payload capture** — Store and hash files uploaded by attackers.
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
- [x] **Decky Inventory** — Dedicated "Decoy Fleet" page showing all deployed assets.
|
||||
- [ ] **Pre-built Kibana/Grafana dashboards** — Ship JSON exports for ELK/Grafana.
|
||||
- [ ] **CLI live feed** — `decnet watch` command for a unified, colored terminal stream.
|
||||
- [ ] **Traversal graph export** — Export attacker movement as DOT or JSON.
|
||||
- [x] **Traversal graph export** — Export attacker movement as JSON (via CLI).
|
||||
|
||||
## Deployment & Infrastructure
|
||||
|
||||
|
||||
419
development/ast_graph.md
Normal file
419
development/ast_graph.md
Normal file
@@ -0,0 +1,419 @@
|
||||
# DECNET Codebase AST Graph
|
||||
|
||||
This diagram shows the structural organization of the DECNET project, extracted directly from the Python Abstract Syntax Tree (AST). It includes modules (prefixed with `Module_`), their internal functions, and the classes and methods they contain.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Module_distros {
|
||||
+random_hostname()
|
||||
+get_distro()
|
||||
+random_distro()
|
||||
+all_distros()
|
||||
}
|
||||
class distros_DistroProfile {
|
||||
}
|
||||
Module_distros ..> distros_DistroProfile : contains
|
||||
|
||||
class custom_service_CustomService {
|
||||
+__init__()
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_custom_service ..> custom_service_CustomService : contains
|
||||
class Module_os_fingerprint {
|
||||
+get_os_sysctls()
|
||||
+all_os_families()
|
||||
}
|
||||
|
||||
class Module_network {
|
||||
+_run()
|
||||
+detect_interface()
|
||||
+detect_subnet()
|
||||
+get_host_ip()
|
||||
+allocate_ips()
|
||||
+create_macvlan_network()
|
||||
+create_ipvlan_network()
|
||||
+remove_macvlan_network()
|
||||
+_require_root()
|
||||
+setup_host_macvlan()
|
||||
+teardown_host_macvlan()
|
||||
+setup_host_ipvlan()
|
||||
+teardown_host_ipvlan()
|
||||
+ips_to_range()
|
||||
}
|
||||
|
||||
class Module_env {
|
||||
+_port()
|
||||
+_require_env()
|
||||
}
|
||||
|
||||
class Module_config {
|
||||
+random_hostname()
|
||||
+save_state()
|
||||
+load_state()
|
||||
+clear_state()
|
||||
}
|
||||
class config_DeckyConfig {
|
||||
+services_not_empty()
|
||||
}
|
||||
Module_config ..> config_DeckyConfig : contains
|
||||
class config_DecnetConfig {
|
||||
}
|
||||
Module_config ..> config_DecnetConfig : contains
|
||||
class Module_ini_loader {
|
||||
+load_ini()
|
||||
+load_ini_from_string()
|
||||
+validate_ini_string()
|
||||
+_parse_configparser()
|
||||
}
|
||||
class ini_loader_DeckySpec {
|
||||
}
|
||||
Module_ini_loader ..> ini_loader_DeckySpec : contains
|
||||
class ini_loader_CustomServiceSpec {
|
||||
}
|
||||
Module_ini_loader ..> ini_loader_CustomServiceSpec : contains
|
||||
class ini_loader_IniConfig {
|
||||
}
|
||||
Module_ini_loader ..> ini_loader_IniConfig : contains
|
||||
class Module_composer {
|
||||
+generate_compose()
|
||||
+write_compose()
|
||||
}
|
||||
|
||||
class Module_archetypes {
|
||||
+get_archetype()
|
||||
+all_archetypes()
|
||||
+random_archetype()
|
||||
}
|
||||
class archetypes_Archetype {
|
||||
}
|
||||
Module_archetypes ..> archetypes_Archetype : contains
|
||||
class Module_fleet {
|
||||
+all_service_names()
|
||||
+resolve_distros()
|
||||
+build_deckies()
|
||||
+build_deckies_from_ini()
|
||||
}
|
||||
|
||||
class Module_cli {
|
||||
+_kill_api()
|
||||
+api()
|
||||
+deploy()
|
||||
+collect()
|
||||
+mutate()
|
||||
+status()
|
||||
+teardown()
|
||||
+list_services()
|
||||
+list_distros()
|
||||
+correlate()
|
||||
+list_archetypes()
|
||||
+serve_web()
|
||||
}
|
||||
|
||||
|
||||
class services_base_BaseService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_base ..> services_base_BaseService : contains
|
||||
|
||||
class services_http_HTTPService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_http ..> services_http_HTTPService : contains
|
||||
|
||||
class services_smtp_SMTPService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_smtp ..> services_smtp_SMTPService : contains
|
||||
|
||||
class services_mysql_MySQLService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_mysql ..> services_mysql_MySQLService : contains
|
||||
|
||||
class services_redis_RedisService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_redis ..> services_redis_RedisService : contains
|
||||
|
||||
class services_elasticsearch_ElasticsearchService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_elasticsearch ..> services_elasticsearch_ElasticsearchService : contains
|
||||
|
||||
class services_ftp_FTPService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_ftp ..> services_ftp_FTPService : contains
|
||||
|
||||
class services_imap_IMAPService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_imap ..> services_imap_IMAPService : contains
|
||||
|
||||
class services_k8s_KubernetesAPIService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_k8s ..> services_k8s_KubernetesAPIService : contains
|
||||
|
||||
class services_ldap_LDAPService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_ldap ..> services_ldap_LDAPService : contains
|
||||
|
||||
class services_llmnr_LLMNRService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_llmnr ..> services_llmnr_LLMNRService : contains
|
||||
|
||||
class services_mongodb_MongoDBService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_mongodb ..> services_mongodb_MongoDBService : contains
|
||||
|
||||
class services_mqtt_MQTTService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_mqtt ..> services_mqtt_MQTTService : contains
|
||||
|
||||
class services_mssql_MSSQLService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_mssql ..> services_mssql_MSSQLService : contains
|
||||
|
||||
class services_pop3_POP3Service {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_pop3 ..> services_pop3_POP3Service : contains
|
||||
|
||||
class services_postgres_PostgresService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_postgres ..> services_postgres_PostgresService : contains
|
||||
|
||||
class services_rdp_RDPService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_rdp ..> services_rdp_RDPService : contains
|
||||
|
||||
class services_sip_SIPService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_sip ..> services_sip_SIPService : contains
|
||||
|
||||
class services_smb_SMBService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_smb ..> services_smb_SMBService : contains
|
||||
|
||||
class services_snmp_SNMPService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_snmp ..> services_snmp_SNMPService : contains
|
||||
|
||||
class services_tftp_TFTPService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_tftp ..> services_tftp_TFTPService : contains
|
||||
|
||||
class services_vnc_VNCService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_vnc ..> services_vnc_VNCService : contains
|
||||
|
||||
class services_docker_api_DockerAPIService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_docker_api ..> services_docker_api_DockerAPIService : contains
|
||||
class Module_services_registry {
|
||||
+_load_plugins()
|
||||
+register_custom_service()
|
||||
+get_service()
|
||||
+all_services()
|
||||
}
|
||||
|
||||
|
||||
class services_smtp_relay_SMTPRelayService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_smtp_relay ..> services_smtp_relay_SMTPRelayService : contains
|
||||
|
||||
class services_conpot_ConpotService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_conpot ..> services_conpot_ConpotService : contains
|
||||
|
||||
class services_ssh_SSHService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_ssh ..> services_ssh_SSHService : contains
|
||||
|
||||
class services_telnet_TelnetService {
|
||||
+compose_fragment()
|
||||
+dockerfile_context()
|
||||
}
|
||||
Module_services_telnet ..> services_telnet_TelnetService : contains
|
||||
class Module_logging_forwarder {
|
||||
+parse_log_target()
|
||||
+probe_log_target()
|
||||
}
|
||||
|
||||
class Module_logging_file_handler {
|
||||
+_get_logger()
|
||||
+write_syslog()
|
||||
+get_log_path()
|
||||
}
|
||||
|
||||
class Module_logging_syslog_formatter {
|
||||
+_pri()
|
||||
+_truncate()
|
||||
+_sd_escape()
|
||||
+_sd_element()
|
||||
+format_rfc5424()
|
||||
}
|
||||
|
||||
|
||||
class correlation_graph_TraversalHop {
|
||||
}
|
||||
Module_correlation_graph ..> correlation_graph_TraversalHop : contains
|
||||
class correlation_graph_AttackerTraversal {
|
||||
+first_seen()
|
||||
+last_seen()
|
||||
+duration_seconds()
|
||||
+deckies()
|
||||
+decky_count()
|
||||
+path()
|
||||
+to_dict()
|
||||
}
|
||||
Module_correlation_graph ..> correlation_graph_AttackerTraversal : contains
|
||||
class Module_correlation_engine {
|
||||
+_fmt_duration()
|
||||
}
|
||||
class correlation_engine_CorrelationEngine {
|
||||
+__init__()
|
||||
+ingest()
|
||||
+ingest_file()
|
||||
+traversals()
|
||||
+all_attackers()
|
||||
+report_table()
|
||||
+report_json()
|
||||
+traversal_syslog_lines()
|
||||
}
|
||||
Module_correlation_engine ..> correlation_engine_CorrelationEngine : contains
|
||||
class Module_correlation_parser {
|
||||
+_parse_sd_params()
|
||||
+_extract_attacker_ip()
|
||||
+parse_line()
|
||||
}
|
||||
class correlation_parser_LogEvent {
|
||||
}
|
||||
Module_correlation_parser ..> correlation_parser_LogEvent : contains
|
||||
class Module_web_auth {
|
||||
+verify_password()
|
||||
+get_password_hash()
|
||||
+create_access_token()
|
||||
}
|
||||
|
||||
class Module_engine_deployer {
|
||||
+_sync_logging_helper()
|
||||
+_compose()
|
||||
+_compose_with_retry()
|
||||
+deploy()
|
||||
+teardown()
|
||||
+status()
|
||||
+_print_status()
|
||||
}
|
||||
|
||||
class Module_collector_worker {
|
||||
+parse_rfc5424()
|
||||
+_load_service_container_names()
|
||||
+is_service_container()
|
||||
+is_service_event()
|
||||
+_stream_container()
|
||||
}
|
||||
|
||||
class Module_mutator_engine {
|
||||
+mutate_decky()
|
||||
+mutate_all()
|
||||
+run_watch_loop()
|
||||
}
|
||||
|
||||
|
||||
class web_db_repository_BaseRepository {
|
||||
}
|
||||
Module_web_db_repository ..> web_db_repository_BaseRepository : contains
|
||||
|
||||
class web_db_models_User {
|
||||
}
|
||||
Module_web_db_models ..> web_db_models_User : contains
|
||||
class web_db_models_Log {
|
||||
}
|
||||
Module_web_db_models ..> web_db_models_Log : contains
|
||||
class web_db_models_Bounty {
|
||||
}
|
||||
Module_web_db_models ..> web_db_models_Bounty : contains
|
||||
class web_db_models_Token {
|
||||
}
|
||||
Module_web_db_models ..> web_db_models_Token : contains
|
||||
class web_db_models_LoginRequest {
|
||||
}
|
||||
Module_web_db_models ..> web_db_models_LoginRequest : contains
|
||||
class web_db_models_ChangePasswordRequest {
|
||||
}
|
||||
Module_web_db_models ..> web_db_models_ChangePasswordRequest : contains
|
||||
class web_db_models_LogsResponse {
|
||||
}
|
||||
Module_web_db_models ..> web_db_models_LogsResponse : contains
|
||||
class web_db_models_BountyResponse {
|
||||
}
|
||||
Module_web_db_models ..> web_db_models_BountyResponse : contains
|
||||
class web_db_models_StatsResponse {
|
||||
}
|
||||
Module_web_db_models ..> web_db_models_StatsResponse : contains
|
||||
class web_db_models_MutateIntervalRequest {
|
||||
}
|
||||
Module_web_db_models ..> web_db_models_MutateIntervalRequest : contains
|
||||
class web_db_models_DeployIniRequest {
|
||||
}
|
||||
Module_web_db_models ..> web_db_models_DeployIniRequest : contains
|
||||
class Module_web_db_sqlite_database {
|
||||
+get_async_engine()
|
||||
+get_sync_engine()
|
||||
+init_db()
|
||||
}
|
||||
|
||||
|
||||
class web_db_sqlite_repository_SQLiteRepository {
|
||||
+__init__()
|
||||
+_initialize_sync()
|
||||
+_apply_filters()
|
||||
+_apply_bounty_filters()
|
||||
}
|
||||
Module_web_db_sqlite_repository ..> web_db_sqlite_repository_SQLiteRepository : contains
|
||||
```
|
||||
192
development/complete_execution_graph.md
Normal file
192
development/complete_execution_graph.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# DECNET: Complete Execution Graph
|
||||
|
||||
This diagram represents the absolute complete call graph of the DECNET project. It connects initial entry points (CLI and Web API) through the orchestration layers, down to the low-level network and service container logic.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph CLI_Entry
|
||||
cli__kill_api([_kill_api])
|
||||
cli_api([api])
|
||||
cli_deploy([deploy])
|
||||
cli_collect([collect])
|
||||
cli_mutate([mutate])
|
||||
cli_status([status])
|
||||
cli_teardown([teardown])
|
||||
cli_list_services([list_services])
|
||||
cli_list_distros([list_distros])
|
||||
cli_correlate([correlate])
|
||||
cli_list_archetypes([list_archetypes])
|
||||
cli_serve_web([serve_web])
|
||||
cli_do_GET([do_GET])
|
||||
end
|
||||
subgraph Fleet_Management
|
||||
distros_random_hostname([distros_random_hostname])
|
||||
distros_get_distro([distros_get_distro])
|
||||
distros_random_distro([distros_random_distro])
|
||||
distros_all_distros([distros_all_distros])
|
||||
ini_loader_load_ini([ini_loader_load_ini])
|
||||
ini_loader_load_ini_from_string([ini_loader_load_ini_from_string])
|
||||
ini_loader_validate_ini_string([ini_loader_validate_ini_string])
|
||||
ini_loader__parse_configparser([ini_loader__parse_configparser])
|
||||
archetypes_get_archetype([archetypes_get_archetype])
|
||||
archetypes_all_archetypes([archetypes_all_archetypes])
|
||||
archetypes_random_archetype([archetypes_random_archetype])
|
||||
fleet_all_service_names([all_service_names])
|
||||
fleet_resolve_distros([resolve_distros])
|
||||
fleet_build_deckies([build_deckies])
|
||||
fleet_build_deckies_from_ini([build_deckies_from_ini])
|
||||
end
|
||||
subgraph Deployment_Engine
|
||||
network__run([network__run])
|
||||
network_detect_interface([network_detect_interface])
|
||||
network_detect_subnet([network_detect_subnet])
|
||||
network_get_host_ip([network_get_host_ip])
|
||||
network_allocate_ips([network_allocate_ips])
|
||||
network_create_macvlan_network([network_create_macvlan_network])
|
||||
network_create_ipvlan_network([network_create_ipvlan_network])
|
||||
network_remove_macvlan_network([network_remove_macvlan_network])
|
||||
network__require_root([network__require_root])
|
||||
network_setup_host_macvlan([network_setup_host_macvlan])
|
||||
network_teardown_host_macvlan([network_teardown_host_macvlan])
|
||||
network_setup_host_ipvlan([network_setup_host_ipvlan])
|
||||
network_teardown_host_ipvlan([network_teardown_host_ipvlan])
|
||||
network_ips_to_range([network_ips_to_range])
|
||||
config_random_hostname([config_random_hostname])
|
||||
config_save_state([config_save_state])
|
||||
config_load_state([config_load_state])
|
||||
config_clear_state([config_clear_state])
|
||||
composer_generate_compose([composer_generate_compose])
|
||||
composer_write_compose([composer_write_compose])
|
||||
engine_deployer__sync_logging_helper([_sync_logging_helper])
|
||||
engine_deployer__compose([_compose])
|
||||
engine_deployer__compose_with_retry([_compose_with_retry])
|
||||
engine_deployer_deploy([deploy])
|
||||
engine_deployer_teardown([teardown])
|
||||
engine_deployer_status([status])
|
||||
engine_deployer__print_status([_print_status])
|
||||
end
|
||||
subgraph Monitoring_Mutation
|
||||
collector_worker_parse_rfc5424([parse_rfc5424])
|
||||
collector_worker__load_service_container_names([_load_service_container_names])
|
||||
collector_worker_is_service_container([is_service_container])
|
||||
collector_worker_is_service_event([is_service_event])
|
||||
collector_worker__stream_container([_stream_container])
|
||||
collector_worker_log_collector_worker([log_collector_worker])
|
||||
collector_worker__spawn([_spawn])
|
||||
collector_worker__watch_events([_watch_events])
|
||||
mutator_engine_mutate_decky([mutate_decky])
|
||||
mutator_engine_mutate_all([mutate_all])
|
||||
mutator_engine_run_watch_loop([run_watch_loop])
|
||||
end
|
||||
subgraph Web_Service
|
||||
web_auth_verify_password([web_auth_verify_password])
|
||||
web_auth_get_password_hash([web_auth_get_password_hash])
|
||||
web_auth_create_access_token([web_auth_create_access_token])
|
||||
web_db_repository_initialize([web_db_repository_initialize])
|
||||
web_db_repository_add_log([web_db_repository_add_log])
|
||||
web_db_repository_get_logs([web_db_repository_get_logs])
|
||||
web_db_repository_get_total_logs([web_db_repository_get_total_logs])
|
||||
web_db_repository_get_stats_summary([web_db_repository_get_stats_summary])
|
||||
web_db_repository_get_deckies([web_db_repository_get_deckies])
|
||||
web_db_repository_get_user_by_uuid([web_db_repository_get_user_by_uuid])
|
||||
web_db_repository_update_user_password([web_db_repository_update_user_password])
|
||||
web_db_repository_add_bounty([web_db_repository_add_bounty])
|
||||
web_db_repository_get_bounties([web_db_repository_get_bounties])
|
||||
web_db_repository_get_total_bounties([web_db_repository_get_total_bounties])
|
||||
web_db_sqlite_database_get_async_engine([web_db_sqlite_database_get_async_engine])
|
||||
web_db_sqlite_database_get_sync_engine([web_db_sqlite_database_get_sync_engine])
|
||||
web_db_sqlite_database_init_db([web_db_sqlite_database_init_db])
|
||||
web_db_sqlite_repository_initialize([web_db_sqlite_repository_initialize])
|
||||
web_db_sqlite_repository_reinitialize([web_db_sqlite_repository_reinitialize])
|
||||
web_db_sqlite_repository_add_log([web_db_sqlite_repository_add_log])
|
||||
web_db_sqlite_repository__apply_filters([web_db_sqlite_repository__apply_filters])
|
||||
web_db_sqlite_repository_get_logs([web_db_sqlite_repository_get_logs])
|
||||
web_db_sqlite_repository_get_max_log_id([web_db_sqlite_repository_get_max_log_id])
|
||||
web_db_sqlite_repository_get_logs_after_id([web_db_sqlite_repository_get_logs_after_id])
|
||||
web_db_sqlite_repository_get_total_logs([web_db_sqlite_repository_get_total_logs])
|
||||
web_db_sqlite_repository_get_log_histogram([web_db_sqlite_repository_get_log_histogram])
|
||||
web_db_sqlite_repository_get_stats_summary([web_db_sqlite_repository_get_stats_summary])
|
||||
web_db_sqlite_repository_get_deckies([web_db_sqlite_repository_get_deckies])
|
||||
web_db_sqlite_repository_get_user_by_username([web_db_sqlite_repository_get_user_by_username])
|
||||
web_db_sqlite_repository_get_user_by_uuid([web_db_sqlite_repository_get_user_by_uuid])
|
||||
web_db_sqlite_repository_create_user([web_db_sqlite_repository_create_user])
|
||||
web_db_sqlite_repository_update_user_password([web_db_sqlite_repository_update_user_password])
|
||||
web_db_sqlite_repository_add_bounty([web_db_sqlite_repository_add_bounty])
|
||||
web_db_sqlite_repository__apply_bounty_filters([web_db_sqlite_repository__apply_bounty_filters])
|
||||
web_db_sqlite_repository_get_bounties([web_db_sqlite_repository_get_bounties])
|
||||
web_db_sqlite_repository_get_total_bounties([web_db_sqlite_repository_get_total_bounties])
|
||||
web_router_auth_api_change_pass_change_password([auth_api_change_pass_change_password])
|
||||
web_router_auth_api_login_login([auth_api_login_login])
|
||||
web_router_logs_api_get_logs_get_logs([logs_api_get_logs_get_logs])
|
||||
web_router_logs_api_get_histogram_get_logs_histogram([logs_api_get_histogram_get_logs_histogram])
|
||||
web_router_bounty_api_get_bounties_get_bounties([bounty_api_get_bounties_get_bounties])
|
||||
web_router_stats_api_get_stats_get_stats([stats_api_get_stats_get_stats])
|
||||
web_router_fleet_api_mutate_decky_api_mutate_decky([api_mutate_decky_api_mutate_decky])
|
||||
web_router_fleet_api_get_deckies_get_deckies([api_get_deckies_get_deckies])
|
||||
web_router_fleet_api_mutate_interval_api_update_mutate_interval([api_mutate_interval_api_update_mutate_interval])
|
||||
web_router_fleet_api_deploy_deckies_api_deploy_deckies([api_deploy_deckies_api_deploy_deckies])
|
||||
web_router_stream_api_stream_events_stream_events([stream_api_stream_events_stream_events])
|
||||
web_router_stream_api_stream_events_event_generator([stream_api_stream_events_event_generator])
|
||||
end
|
||||
|
||||
%% Key Connection Edges
|
||||
network_detect_interface --> network__run
|
||||
network_detect_subnet --> network__run
|
||||
network_get_host_ip --> network__run
|
||||
network_setup_host_macvlan --> network__run
|
||||
network_teardown_host_macvlan --> network__run
|
||||
network_setup_host_ipvlan --> network__run
|
||||
network_teardown_host_ipvlan --> network__run
|
||||
|
||||
ini_loader_load_ini --> ini_loader__parse_configparser
|
||||
ini_loader_load_ini_from_string --> ini_loader__parse_configparser
|
||||
|
||||
composer_generate_compose --> os_fingerprint_get_os_sysctls
|
||||
composer_write_compose --> composer_generate_compose
|
||||
|
||||
fleet_resolve_distros --> distros_random_distro
|
||||
fleet_build_deckies --> fleet_resolve_distros
|
||||
fleet_build_deckies --> config_random_hostname
|
||||
fleet_build_deckies_from_ini --> archetypes_get_archetype
|
||||
fleet_build_deckies_from_ini --> fleet_all_service_names
|
||||
|
||||
cli_deploy --> ini_loader_load_ini
|
||||
cli_deploy --> network_detect_interface
|
||||
cli_deploy --> fleet_build_deckies_from_ini
|
||||
cli_deploy --> engine_deployer_deploy
|
||||
|
||||
cli_collect --> collector_worker_log_collector_worker
|
||||
cli_mutate --> mutator_engine_run_watch_loop
|
||||
|
||||
cli_correlate --> correlation_engine_ingest_file
|
||||
cli_correlate --> correlation_engine_traversals
|
||||
|
||||
engine_deployer_deploy --> network_ips_to_range
|
||||
engine_deployer_deploy --> network_setup_host_macvlan
|
||||
engine_deployer_deploy --> composer_write_compose
|
||||
engine_deployer_deploy --> engine_deployer__compose_with_retry
|
||||
|
||||
engine_deployer_teardown --> network_teardown_host_macvlan
|
||||
engine_deployer_teardown --> config_clear_state
|
||||
|
||||
collector_worker_log_collector_worker --> collector_worker__stream_container
|
||||
collector_worker__stream_container --> collector_worker_parse_rfc5424
|
||||
|
||||
mutator_engine_mutate_decky --> composer_write_compose
|
||||
mutator_engine_mutate_decky --> engine_deployer__compose_with_retry
|
||||
mutator_engine_mutate_all --> mutator_engine_mutate_decky
|
||||
mutator_engine_run_watch_loop --> mutator_engine_mutate_all
|
||||
|
||||
web_db_sqlite_repository_initialize --> web_db_sqlite_database_init_db
|
||||
web_db_sqlite_repository_get_logs --> web_db_sqlite_repository__apply_filters
|
||||
|
||||
web_router_auth_api_login_login --> web_auth_verify_password
|
||||
web_router_auth_api_login_login --> web_auth_create_access_token
|
||||
|
||||
web_router_logs_api_get_logs_get_logs --> web_db_sqlite_repository_get_logs
|
||||
web_router_fleet_api_mutate_decky_api_mutate_decky --> mutator_engine_mutate_decky
|
||||
web_router_fleet_api_deploy_deckies_api_deploy_deckies --> fleet_build_deckies_from_ini
|
||||
|
||||
web_router_stream_api_stream_events_stream_events --> web_db_sqlite_repository_get_logs_after_id
|
||||
web_router_stream_api_stream_events_stream_events --> web_router_stream_api_stream_events_event_generator
|
||||
```
|
||||
66
development/execution_graphs.md
Normal file
66
development/execution_graphs.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# DECNET Execution Graphs
|
||||
|
||||
These graphs illustrate the logical flow of execution within the DECNET framework, showing how high-level commands and API requests trigger secondary processes and subsystem interactions.
|
||||
|
||||
## 1. Deployment & Teardown Flow
|
||||
This flow shows the orchestration from a CLI `deploy` command down to network setup and container instantiation.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
CLI_Deploy([cli.deploy]) --> INI[ini_loader.load_ini]
|
||||
CLI_Deploy --> NET_Detect[network.detect_interface]
|
||||
CLI_Deploy --> FleetBuild[fleet.build_deckies_from_ini]
|
||||
|
||||
FleetBuild --> Archetype[archetypes.get_archetype]
|
||||
FleetBuild --> Distro[distros.get_distro]
|
||||
|
||||
CLI_Deploy --> Engine_Deploy[engine.deployer.deploy]
|
||||
|
||||
Engine_Deploy --> IP_Alloc[network.allocate_ips]
|
||||
Engine_Deploy --> NET_Setup[network.setup_host_macvlan]
|
||||
Engine_Deploy --> Compose_Gen[composer.write_compose]
|
||||
Engine_Deploy --> Docker_Up[engine.deployer._compose_with_retry]
|
||||
|
||||
CLI_Teardown([cli.teardown]) --> Engine_Teardown[engine.deployer.teardown]
|
||||
Engine_Teardown --> NET_Cleanup[network.teardown_host_macvlan]
|
||||
Engine_Teardown --> Docker_Down[engine.deployer._compose]
|
||||
```
|
||||
|
||||
## 2. Mutation & Monitoring Flow
|
||||
How DECNET maintains deception by periodically changing decoy identities and monitoring activities.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Periodic_Process
|
||||
CLI_Mutate([cli.mutate]) --> Mutate_Loop[mutator.engine.run_watch_loop]
|
||||
end
|
||||
|
||||
Mutate_Loop --> Mutate_All[mutator.engine.mutate_all]
|
||||
Mutate_All --> Mutate_Decky[mutator.engine.mutate_decky]
|
||||
|
||||
Mutate_Decky --> Get_New_Identity[archetypes.get_archetype]
|
||||
Mutate_Decky --> Rewrite_Compose[composer.write_compose]
|
||||
Mutate_Decky --> Restart_Container[engine.deployer._compose_with_retry]
|
||||
|
||||
subgraph Log_Collection
|
||||
CLI_Collect([cli.collect]) --> Worker[collector.worker.log_collector_worker]
|
||||
Worker --> Stream[collector.worker._stream_container]
|
||||
Stream --> Parse[collector.worker.parse_rfc5424]
|
||||
end
|
||||
```
|
||||
|
||||
## 3. Web API Flow (Fleet Management)
|
||||
How the Web UI interacts with the underlying systems via the FastAPI router.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Web_UI[Web Dashboard] --> API_Deploy[web.router.fleet.deploy_deckies]
|
||||
Web_UI --> API_Mutate[web.router.fleet.mutate_decky]
|
||||
Web_UI --> API_Stream[web.router.stream.stream_events]
|
||||
|
||||
API_Deploy --> FleetBuild[fleet.build_deckies_from_ini]
|
||||
API_Mutate --> Mutator[mutator.engine.mutate_decky]
|
||||
|
||||
API_Stream --> DB_Pull[web.db.sqlite.repository.get_logs_after_id]
|
||||
DB_Pull --> SQLite[(SQLite Database)]
|
||||
```
|
||||
102
development/mermaid.svg
Normal file
102
development/mermaid.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 528 KiB |
@@ -6,7 +6,6 @@ forwards events as JSON to LOG_TARGET if set.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from twisted.internet import defer, reactor
|
||||
|
||||
@@ -18,7 +18,7 @@ NODE_NAME = os.environ.get("NODE_NAME", "mailserver")
|
||||
SERVICE_NAME = "imap"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
PORT = int(os.environ.get("PORT", "143"))
|
||||
IMAP_BANNER = os.environ.get("IMAP_BANNER", f"* OK Dovecot ready.\r\n")
|
||||
IMAP_BANNER = os.environ.get("IMAP_BANNER", "* OK Dovecot ready.\r\n")
|
||||
_RAW_USERS = os.environ.get("IMAP_USERS", "admin:admin123,root:toor,mail:mail,user:user")
|
||||
|
||||
VALID_USERS: dict[str, str] = {
|
||||
|
||||
@@ -7,7 +7,6 @@ LOG_TARGET if set.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from twisted.internet import protocol, reactor
|
||||
from twisted.python import log as twisted_log
|
||||
|
||||
192
test-full.ini
192
test-full.ini
@@ -1,192 +0,0 @@
|
||||
# DECNET Full Test Config
|
||||
# Covers all 25 registered services across 10 role-themed deckies + archetype pool.
|
||||
# Distros are auto-cycled for heterogeneity (9 profiles, round-robin).
|
||||
#
|
||||
# nmap_os controls the TCP/IP stack sysctls injected into each decky's base
|
||||
# container so nmap OS detection returns the expected OS family:
|
||||
# linux → TTL 64, syn_retries 6
|
||||
# windows → TTL 128, syn_retries 2, large recv buffer
|
||||
# embedded → TTL 255, syn_retries 3
|
||||
# bsd → TTL 64, syn_retries 6
|
||||
# cisco → TTL 255, syn_retries 2
|
||||
#
|
||||
# Usage:
|
||||
# decnet deploy --config test-full.ini --dry-run
|
||||
# sudo decnet deploy --config test-full.ini --log-target 192.168.1.200:5140 \
|
||||
# --log-file /var/log/decnet/decnet.log
|
||||
|
||||
[general]
|
||||
net = 192.168.1.0/24
|
||||
gw = 192.168.1.1
|
||||
interface = wlp6s0
|
||||
#log_target = 192.168.1.200:5140
|
||||
|
||||
# ── Archetype pool: 10 Windows workstations ───────────────────────────────────
|
||||
# archetype=windows-workstation already sets nmap_os=windows automatically.
|
||||
|
||||
[windows-workstation]
|
||||
archetype = windows-workstation
|
||||
amount = 10
|
||||
|
||||
|
||||
# ── Web / Mail stack ──────────────────────────────────────────────────────────
|
||||
# Looks like an internet-facing Linux mail + web host
|
||||
|
||||
[decky-webmail]
|
||||
ip = 192.168.1.110
|
||||
services = http, smtp, imap, pop3
|
||||
nmap_os = linux
|
||||
|
||||
[decky-webmail.http]
|
||||
server_header = Apache/2.4.54 (Debian)
|
||||
response_code = 200
|
||||
fake_app = wordpress
|
||||
|
||||
[decky-webmail.smtp]
|
||||
smtp_banner = 220 mail.corp.local ESMTP Postfix (Debian/GNU)
|
||||
smtp_mta = mail.corp.local
|
||||
|
||||
|
||||
# ── File / Transfer services ──────────────────────────────────────────────────
|
||||
# Presents as a Windows/Samba file server — TTL 128 seals the illusion.
|
||||
|
||||
[decky-fileserv]
|
||||
ip = 192.168.1.111
|
||||
services = smb, ftp, tftp
|
||||
nmap_os = windows
|
||||
|
||||
[decky-fileserv.smb]
|
||||
workgroup = CORP
|
||||
server_name = FILESERV01
|
||||
os_version = Windows Server 2019
|
||||
|
||||
|
||||
# ── LAMP-style database host ──────────────────────────────────────────────────
|
||||
|
||||
[decky-dbsrv01]
|
||||
ip = 192.168.1.112
|
||||
services = mysql, redis
|
||||
nmap_os = linux
|
||||
|
||||
[decky-dbsrv01.mysql]
|
||||
mysql_version = 5.7.38-log
|
||||
mysql_banner = MySQL Community Server
|
||||
|
||||
[decky-dbsrv01.redis]
|
||||
redis_version = 6.2.7
|
||||
|
||||
|
||||
# ── Modern stack databases ────────────────────────────────────────────────────
|
||||
|
||||
[decky-dbsrv02]
|
||||
ip = 192.168.1.113
|
||||
services = postgres, mongodb, elasticsearch
|
||||
nmap_os = linux
|
||||
|
||||
[decky-dbsrv02.postgres]
|
||||
pg_version = 14.5
|
||||
|
||||
[decky-dbsrv02.mongodb]
|
||||
mongo_version = 5.0.9
|
||||
|
||||
[decky-dbsrv02.elasticsearch]
|
||||
es_version = 8.4.3
|
||||
cluster_name = prod-search
|
||||
|
||||
|
||||
# ── Windows workstation / server ──────────────────────────────────────────────
|
||||
# RDP + SMB + MSSQL — nmap_os=windows gives TTL 128 to complete the fingerprint.
|
||||
|
||||
[decky-winbox]
|
||||
ip = 192.168.1.114
|
||||
services = rdp, smb, mssql
|
||||
nmap_os = windows
|
||||
|
||||
[decky-winbox.rdp]
|
||||
os_version = Windows Server 2016
|
||||
build = 14393
|
||||
|
||||
[decky-winbox.smb]
|
||||
workgroup = CORP
|
||||
server_name = WINSRV-DC01
|
||||
os_version = Windows Server 2016
|
||||
|
||||
[decky-winbox.mssql]
|
||||
mssql_version = Microsoft SQL Server 2019
|
||||
|
||||
|
||||
# ── DevOps / Container infra ──────────────────────────────────────────────────
|
||||
|
||||
[decky-devops]
|
||||
ip = 192.168.1.115
|
||||
services = k8s, docker_api
|
||||
nmap_os = linux
|
||||
|
||||
[decky-devops.k8s]
|
||||
k8s_version = v1.26.3
|
||||
|
||||
[decky-devops.docker_api]
|
||||
docker_version = 24.0.2
|
||||
|
||||
|
||||
# ── Directory / Auth services ─────────────────────────────────────────────────
|
||||
# Active Directory DC persona — Windows TCP stack matches the LDAP/SMB services.
|
||||
|
||||
[decky-ldapdc]
|
||||
ip = 192.168.1.116
|
||||
services = ldap, ssh
|
||||
nmap_os = windows
|
||||
|
||||
[decky-ldapdc.ldap]
|
||||
base_dn = dc=corp,dc=local
|
||||
domain = corp.local
|
||||
|
||||
[decky-ldapdc.ssh]
|
||||
ssh_version = OpenSSH_8.9p1 Ubuntu-3ubuntu0.6
|
||||
kernel_version = 5.15.0-91-generic
|
||||
users = root:toor,admin:admin123,svc_backup:backup2024
|
||||
|
||||
|
||||
# ── IoT / Industrial / Network management ─────────────────────────────────────
|
||||
# TTL 255 is the embedded/network-device giveaway nmap looks for.
|
||||
|
||||
[decky-iot]
|
||||
ip = 192.168.1.117
|
||||
services = mqtt, snmp, conpot
|
||||
nmap_os = embedded
|
||||
|
||||
[decky-iot.mqtt]
|
||||
mqtt_version = Mosquitto 2.0.15
|
||||
|
||||
[decky-iot.snmp]
|
||||
snmp_community = public
|
||||
sys_descr = Linux router 5.4.0 #1 SMP x86_64
|
||||
|
||||
|
||||
# ── VoIP / Local network services ────────────────────────────────────────────
|
||||
|
||||
[decky-voip]
|
||||
ip = 192.168.1.118
|
||||
services = sip, llmnr
|
||||
nmap_os = linux
|
||||
|
||||
[decky-voip.sip]
|
||||
sip_server = Asterisk PBX 18.12.0
|
||||
sip_domain = pbx.corp.local
|
||||
|
||||
|
||||
# ── Legacy admin / remote access ─────────────────────────────────────────────
|
||||
# Old-school unpatched box — BSD stack for variety.
|
||||
|
||||
[decky-legacy]
|
||||
ip = 192.168.1.119
|
||||
services = telnet, vnc, ssh
|
||||
nmap_os = bsd
|
||||
|
||||
[decky-legacy.ssh]
|
||||
ssh_version = OpenSSH_7.4p1 Debian-10+deb9u7
|
||||
kernel_version = 4.9.0-19-amd64
|
||||
users = root:root,admin:password,pi:raspberry
|
||||
|
||||
[decky-legacy.vnc]
|
||||
vnc_version = RealVNC 6.7.2
|
||||
41
tests/api/fleet/test_mutate_decky.py
Normal file
41
tests/api/fleet/test_mutate_decky.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Tests for the mutate decky API endpoint.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestMutateDecky:
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_returns_401(self, client: httpx.AsyncClient):
|
||||
resp = await client.post("/api/v1/deckies/decky-01/mutate")
|
||||
assert resp.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_mutation(self, client: httpx.AsyncClient, auth_token: str):
|
||||
with patch("decnet.web.router.fleet.api_mutate_decky.mutate_decky", return_value=True):
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/decky-01/mutate",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "Successfully mutated" in resp.json()["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_mutation_returns_404(self, client: httpx.AsyncClient, auth_token: str):
|
||||
with patch("decnet.web.router.fleet.api_mutate_decky.mutate_decky", return_value=False):
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/decky-01/mutate",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_decky_name_returns_422(self, client: httpx.AsyncClient, auth_token: str):
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/INVALID NAME!!/mutate",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
89
tests/api/fleet/test_mutate_interval.py
Normal file
89
tests/api/fleet/test_mutate_interval.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Tests for the mutate interval API endpoint.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
|
||||
from decnet.config import DeckyConfig, DecnetConfig
|
||||
|
||||
|
||||
def _decky(name: str = "decky-01") -> DeckyConfig:
|
||||
return DeckyConfig(
|
||||
name=name, ip="192.168.1.10", services=["ssh"],
|
||||
distro="debian", base_image="debian", hostname="test-host",
|
||||
build_base="debian:bookworm-slim", nmap_os="linux",
|
||||
mutate_interval=30,
|
||||
)
|
||||
|
||||
|
||||
def _config() -> DecnetConfig:
|
||||
return DecnetConfig(
|
||||
mode="unihost", interface="eth0", subnet="192.168.1.0/24",
|
||||
gateway="192.168.1.1", deckies=[_decky()],
|
||||
)
|
||||
|
||||
|
||||
class TestMutateInterval:
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_returns_401(self, client: httpx.AsyncClient):
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
json={"mutate_interval": 60},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_active_deployment(self, client: httpx.AsyncClient, auth_token: str):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state", return_value=None):
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": 60},
|
||||
)
|
||||
assert resp.status_code == 500
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decky_not_found(self, client: httpx.AsyncClient, auth_token: str):
|
||||
config = _config()
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
|
||||
return_value=(config, Path("test.yml"))):
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/nonexistent/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": 60},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_interval_update(self, client: httpx.AsyncClient, auth_token: str):
|
||||
config = _config()
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
|
||||
return_value=(config, Path("test.yml"))):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.save_state") as mock_save:
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": 120},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "Mutation interval updated"
|
||||
mock_save.assert_called_once()
|
||||
# Verify the interval was actually updated on the decky config
|
||||
assert config.deckies[0].mutate_interval == 120
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_interval_removes_mutation(self, client: httpx.AsyncClient, auth_token: str):
|
||||
config = _config()
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
|
||||
return_value=(config, Path("test.yml"))):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.save_state"):
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": None},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert config.deckies[0].mutate_interval is None
|
||||
1
tests/api/stream/__init__.py
Normal file
1
tests/api/stream/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Stream test package
|
||||
52
tests/api/stream/test_stream_events.py
Normal file
52
tests/api/stream/test_stream_events.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Tests for the SSE stream endpoint (decnet/web/router/stream/api_stream_events.py).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
# ── Stream endpoint tests ─────────────────────────────────────────────────────
|
||||
|
||||
class TestStreamEvents:
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_returns_401(self, client: httpx.AsyncClient):
|
||||
resp = await client.get("/api/v1/stream")
|
||||
assert resp.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_sends_initial_stats(self, client: httpx.AsyncClient, auth_token: str):
|
||||
# We force the generator to exit immediately by making the first awaitable raise
|
||||
with patch("decnet.web.router.stream.api_stream_events.repo") as mock_repo:
|
||||
mock_repo.get_max_log_id = AsyncMock(side_effect=StopAsyncIteration)
|
||||
|
||||
# This will hit the 'except Exception' or just exit the generator
|
||||
resp = await client.get(
|
||||
"/api/v1/stream",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
params={"lastEventId": "0"},
|
||||
)
|
||||
# It might return a 200 with an empty/error stream or a 500 depending on how SSE-starlette handles generator failure
|
||||
# But the important thing is that it FINISHES.
|
||||
assert resp.status_code in (200, 500)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_with_query_token(self, client: httpx.AsyncClient, auth_token: str):
|
||||
# Apply the same crash-fix to avoid hanging
|
||||
with patch("decnet.web.router.stream.api_stream_events.repo") as mock_repo:
|
||||
mock_repo.get_max_log_id = AsyncMock(side_effect=StopAsyncIteration)
|
||||
resp = await client.get(
|
||||
"/api/v1/stream",
|
||||
params={"token": auth_token, "lastEventId": "0"},
|
||||
)
|
||||
assert resp.status_code in (200, 500)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_invalid_token_401(self, client: httpx.AsyncClient):
|
||||
resp = await client.get(
|
||||
"/api/v1/stream",
|
||||
params={"token": "bad-token", "lastEventId": "0"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
@@ -83,7 +83,7 @@ def assert_rfc5424(
|
||||
criteria = {"service": service, "event_type": event_type, **fields}
|
||||
raise AssertionError(
|
||||
f"No RFC 5424 line matching {criteria!r} found among {len(lines)} lines:\n"
|
||||
+ "\n".join(f" {l!r}" for l in lines[:20])
|
||||
+ "\n".join(f" {line!r}" for line in lines[:20])
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -35,5 +35,5 @@ class TestFTPLive:
|
||||
ftp.close()
|
||||
lines = drain()
|
||||
# At least one RFC 5424 line from the ftp service
|
||||
rfc_lines = [l for l in lines if "<" in l and ">1 " in l and "ftp" in l]
|
||||
assert rfc_lines, f"No ftp RFC 5424 lines found. stdout:\n" + "\n".join(lines[:15])
|
||||
rfc_lines = [line for line in lines if "<" in line and ">1 " in line and "ftp" in line]
|
||||
assert rfc_lines, "No ftp RFC 5424 lines found. stdout:\n" + "\n".join(lines[:15])
|
||||
|
||||
@@ -28,8 +28,8 @@ class TestHTTPLive:
|
||||
)
|
||||
lines = drain()
|
||||
# body field present in log line
|
||||
assert any("body=" in l for l in lines if "request" in l), (
|
||||
f"Expected 'body=' in request log line. Got:\n" + "\n".join(lines[:10])
|
||||
assert any("body=" in line for line in lines if "request" in line), (
|
||||
"Expected 'body=' in request log line. Got:\n" + "\n".join(lines[:10])
|
||||
)
|
||||
|
||||
def test_method_and_path_in_log(self, live_service):
|
||||
|
||||
@@ -65,6 +65,6 @@ class TestMongoDBLive:
|
||||
client.close()
|
||||
lines = drain()
|
||||
# At least one message was exchanged
|
||||
assert any("mongodb" in l for l in lines), (
|
||||
assert any("mongodb" in line for line in lines), (
|
||||
"Expected at least one mongodb log line"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ length fields that could cause huge buffer allocations.
|
||||
import importlib.util
|
||||
import struct
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
|
||||
@@ -176,7 +176,7 @@ def test_pop3_list_returns_10_entries(pop3_mod):
|
||||
resp = _replies(written).decode()
|
||||
assert resp.startswith("+OK 10")
|
||||
# Count individual message lines: "N size\r\n"
|
||||
entries = [l for l in resp.split("\r\n") if l and l[0].isdigit()]
|
||||
entries = [entry for entry in resp.split("\r\n") if entry and entry[0].isdigit()]
|
||||
assert len(entries) == 10
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ def test_pop3_top_3_body_lines_count(pop3_mod):
|
||||
parts = resp.split("\r\n\r\n", 1)
|
||||
assert len(parts) == 2
|
||||
body_section = parts[1].rstrip("\r\n.")
|
||||
body_lines = [l for l in body_section.split("\r\n") if l != "."]
|
||||
body_lines = [part for part in body_section.split("\r\n") if part != "."]
|
||||
assert len(body_lines) <= 3
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ def test_pop3_uidl_returns_10_entries(pop3_mod):
|
||||
_send(proto, "UIDL")
|
||||
resp = _replies(written).decode()
|
||||
assert resp.startswith("+OK")
|
||||
entries = [l for l in resp.split("\r\n") if l and l[0].isdigit()]
|
||||
entries = [entry for entry in resp.split("\r\n") if entry and entry[0].isdigit()]
|
||||
assert len(entries) == 10
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ tests for zero/tiny/huge msg_len in both the startup and auth states.
|
||||
import importlib.util
|
||||
import struct
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
|
||||
39
tests/test_base_repo.py
Normal file
39
tests/test_base_repo.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Mock test for BaseRepository to ensure coverage of abstract pass lines.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
|
||||
class DummyRepo(BaseRepository):
|
||||
async def initialize(self) -> None: await super().initialize()
|
||||
async def add_log(self, data): await super().add_log(data)
|
||||
async def get_logs(self, **kw): await super().get_logs(**kw)
|
||||
async def get_total_logs(self, **kw): await super().get_total_logs(**kw)
|
||||
async def get_stats_summary(self): await super().get_stats_summary()
|
||||
async def get_deckies(self): await super().get_deckies()
|
||||
async def get_user_by_username(self, u): await super().get_user_by_username(u)
|
||||
async def get_user_by_uuid(self, u): await super().get_user_by_uuid(u)
|
||||
async def create_user(self, d): await super().create_user(d)
|
||||
async def update_user_password(self, *a, **kw): await super().update_user_password(*a, **kw)
|
||||
async def add_bounty(self, d): await super().add_bounty(d)
|
||||
async def get_bounties(self, **kw): await super().get_bounties(**kw)
|
||||
async def get_total_bounties(self, **kw): await super().get_total_bounties(**kw)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_repo_coverage():
|
||||
dr = DummyRepo()
|
||||
# Call all to hit 'pass' statements
|
||||
await dr.initialize()
|
||||
await dr.add_log({})
|
||||
await dr.get_logs()
|
||||
await dr.get_total_logs()
|
||||
await dr.get_stats_summary()
|
||||
await dr.get_deckies()
|
||||
await dr.get_user_by_username("a")
|
||||
await dr.get_user_by_uuid("a")
|
||||
await dr.create_user({})
|
||||
await dr.update_user_password("a", "b")
|
||||
await dr.add_bounty({})
|
||||
await dr.get_bounties()
|
||||
await dr.get_total_bounties()
|
||||
358
tests/test_cli.py
Normal file
358
tests/test_cli.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""
|
||||
Tests for decnet/cli.py — CLI commands via Typer's CliRunner.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from decnet.cli import app
|
||||
from decnet.config import DeckyConfig, DecnetConfig
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _decky(name: str = "decky-01", ip: str = "192.168.1.10") -> DeckyConfig:
|
||||
return DeckyConfig(
|
||||
name=name, ip=ip, services=["ssh"],
|
||||
distro="debian", base_image="debian", hostname="test-host",
|
||||
build_base="debian:bookworm-slim", nmap_os="linux",
|
||||
)
|
||||
|
||||
|
||||
def _config() -> DecnetConfig:
|
||||
return DecnetConfig(
|
||||
mode="unihost", interface="eth0", subnet="192.168.1.0/24",
|
||||
gateway="192.168.1.1", deckies=[_decky()],
|
||||
)
|
||||
|
||||
|
||||
# ── services command ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestServicesCommand:
|
||||
def test_lists_services(self):
|
||||
result = runner.invoke(app, ["services"])
|
||||
assert result.exit_code == 0
|
||||
assert "ssh" in result.stdout
|
||||
|
||||
|
||||
# ── distros command ───────────────────────────────────────────────────────────
|
||||
|
||||
class TestDistrosCommand:
|
||||
def test_lists_distros(self):
|
||||
result = runner.invoke(app, ["distros"])
|
||||
assert result.exit_code == 0
|
||||
assert "debian" in result.stdout.lower()
|
||||
|
||||
|
||||
# ── archetypes command ────────────────────────────────────────────────────────
|
||||
|
||||
class TestArchetypesCommand:
|
||||
def test_lists_archetypes(self):
|
||||
result = runner.invoke(app, ["archetypes"])
|
||||
assert result.exit_code == 0
|
||||
assert "deaddeck" in result.stdout.lower()
|
||||
|
||||
|
||||
# ── deploy command ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDeployCommand:
|
||||
@patch("decnet.engine.deploy")
|
||||
@patch("decnet.cli.allocate_ips", return_value=["192.168.1.10"])
|
||||
@patch("decnet.cli.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
|
||||
@patch("decnet.cli.detect_interface", return_value="eth0")
|
||||
def test_deploy_dry_run(self, mock_iface, mock_subnet, mock_hip,
|
||||
mock_ips, mock_deploy):
|
||||
result = runner.invoke(app, [
|
||||
"deploy", "--deckies", "1", "--services", "ssh", "--dry-run",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
mock_deploy.assert_called_once()
|
||||
|
||||
def test_deploy_no_interface_found(self):
|
||||
with patch("decnet.cli.detect_interface", side_effect=ValueError("No interface")):
|
||||
result = runner.invoke(app, ["deploy", "--deckies", "1"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_deploy_no_subnet_found(self):
|
||||
with patch("decnet.cli.detect_interface", return_value="eth0"), \
|
||||
patch("decnet.cli.detect_subnet", side_effect=ValueError("No subnet")):
|
||||
result = runner.invoke(app, ["deploy", "--deckies", "1", "--services", "ssh"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_deploy_invalid_mode(self):
|
||||
result = runner.invoke(app, ["deploy", "--mode", "invalid", "--deckies", "1"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
@patch("decnet.cli.detect_interface", return_value="eth0")
|
||||
def test_deploy_no_deckies_no_config(self, mock_iface):
|
||||
result = runner.invoke(app, ["deploy", "--services", "ssh"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
@patch("decnet.cli.detect_interface", return_value="eth0")
|
||||
def test_deploy_no_services_no_randomize(self, mock_iface):
|
||||
result = runner.invoke(app, ["deploy", "--deckies", "1"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
@patch("decnet.engine.deploy")
|
||||
@patch("decnet.cli.allocate_ips", return_value=["192.168.1.10"])
|
||||
@patch("decnet.cli.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
|
||||
@patch("decnet.cli.detect_interface", return_value="eth0")
|
||||
def test_deploy_with_archetype(self, mock_iface, mock_subnet, mock_hip,
|
||||
mock_ips, mock_deploy):
|
||||
result = runner.invoke(app, [
|
||||
"deploy", "--deckies", "1", "--archetype", "deaddeck", "--dry-run",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_deploy_invalid_archetype(self):
|
||||
result = runner.invoke(app, [
|
||||
"deploy", "--deckies", "1", "--archetype", "nonexistent_arch",
|
||||
])
|
||||
assert result.exit_code == 1
|
||||
|
||||
@patch("decnet.engine.deploy")
|
||||
@patch("subprocess.Popen")
|
||||
@patch("decnet.cli.allocate_ips", return_value=["192.168.1.10"])
|
||||
@patch("decnet.cli.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
|
||||
@patch("decnet.cli.detect_interface", return_value="eth0")
|
||||
def test_deploy_full_with_api(self, mock_iface, mock_subnet, mock_hip,
|
||||
mock_ips, mock_popen, mock_deploy):
|
||||
# Test non-dry-run with API and collector starts
|
||||
result = runner.invoke(app, [
|
||||
"deploy", "--deckies", "1", "--services", "ssh", "--api",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
assert mock_popen.call_count >= 1 # API
|
||||
|
||||
@patch("decnet.engine.deploy")
|
||||
@patch("decnet.cli.allocate_ips", return_value=["192.168.1.10"])
|
||||
@patch("decnet.cli.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
|
||||
@patch("decnet.cli.detect_interface", return_value="eth0")
|
||||
def test_deploy_with_distro(self, mock_iface, mock_subnet, mock_hip,
|
||||
mock_ips, mock_deploy):
|
||||
result = runner.invoke(app, [
|
||||
"deploy", "--deckies", "1", "--services", "ssh", "--distro", "debian", "--dry-run",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_deploy_invalid_distro(self):
|
||||
result = runner.invoke(app, [
|
||||
"deploy", "--deckies", "1", "--services", "ssh", "--distro", "nonexistent_distro",
|
||||
])
|
||||
assert result.exit_code == 1
|
||||
|
||||
@patch("decnet.engine.deploy")
|
||||
@patch("decnet.cli.load_ini")
|
||||
@patch("decnet.cli.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1"))
|
||||
@patch("decnet.cli.detect_interface", return_value="eth0")
|
||||
def test_deploy_with_config_file(self, mock_iface, mock_subnet, mock_hip,
|
||||
mock_load_ini, mock_deploy, tmp_path):
|
||||
from decnet.ini_loader import IniConfig, DeckySpec
|
||||
ini_file = tmp_path / "test.ini"
|
||||
ini_file.touch()
|
||||
mock_load_ini.return_value = IniConfig(
|
||||
deckies=[DeckySpec(name="test-1", services=["ssh"], ip="192.168.1.50")],
|
||||
interface="eth0", subnet="192.168.1.0/24", gateway="192.168.1.1",
|
||||
)
|
||||
result = runner.invoke(app, [
|
||||
"deploy", "--config", str(ini_file), "--dry-run",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_deploy_config_file_not_found(self):
|
||||
result = runner.invoke(app, [
|
||||
"deploy", "--config", "/nonexistent/config.ini",
|
||||
])
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
# ── teardown command ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestTeardownCommand:
|
||||
def test_teardown_no_args(self):
|
||||
result = runner.invoke(app, ["teardown"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
@patch("decnet.cli._kill_api")
|
||||
@patch("decnet.engine.teardown")
|
||||
def test_teardown_all(self, mock_teardown, mock_kill):
|
||||
result = runner.invoke(app, ["teardown", "--all"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("decnet.engine.teardown")
|
||||
def test_teardown_by_id(self, mock_teardown):
|
||||
result = runner.invoke(app, ["teardown", "--id", "decky-01"])
|
||||
assert result.exit_code == 0
|
||||
mock_teardown.assert_called_once_with(decky_id="decky-01")
|
||||
|
||||
@patch("decnet.engine.teardown", side_effect=Exception("Teardown failed"))
|
||||
def test_teardown_error(self, mock_teardown):
|
||||
result = runner.invoke(app, ["teardown", "--all"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
@patch("decnet.engine.teardown", side_effect=Exception("Specific ID failed"))
|
||||
def test_teardown_id_error(self, mock_teardown):
|
||||
result = runner.invoke(app, ["teardown", "--id", "decky-01"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
# ── status command ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestStatusCommand:
|
||||
@patch("decnet.engine.status", return_value=[])
|
||||
def test_status_empty(self, mock_status):
|
||||
result = runner.invoke(app, ["status"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("decnet.engine.status", return_value=[{"ID": "1", "Status": "running"}])
|
||||
def test_status_active(self, mock_status):
|
||||
result = runner.invoke(app, ["status"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
# ── mutate command ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestMutateCommand:
|
||||
@patch("decnet.mutator.mutate_all")
|
||||
def test_mutate_default(self, mock_mutate_all):
|
||||
result = runner.invoke(app, ["mutate"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("decnet.mutator.mutate_all")
|
||||
def test_mutate_force_all(self, mock_mutate_all):
|
||||
result = runner.invoke(app, ["mutate", "--all"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("decnet.mutator.mutate_decky")
|
||||
def test_mutate_specific_decky(self, mock_mutate):
|
||||
result = runner.invoke(app, ["mutate", "--decky", "decky-01"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("decnet.mutator.run_watch_loop")
|
||||
def test_mutate_watch(self, mock_watch):
|
||||
result = runner.invoke(app, ["mutate", "--watch"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("decnet.mutator.mutate_all", side_effect=Exception("Mutate error"))
|
||||
def test_mutate_error(self, mock_mutate):
|
||||
result = runner.invoke(app, ["mutate"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
# ── collect command ───────────────────────────────────────────────────────────
|
||||
|
||||
class TestCollectCommand:
|
||||
@patch("asyncio.run")
|
||||
def test_collect(self, mock_run):
|
||||
result = runner.invoke(app, ["collect"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("asyncio.run", side_effect=KeyboardInterrupt)
|
||||
def test_collect_interrupt(self, mock_run):
|
||||
result = runner.invoke(app, ["collect"])
|
||||
assert result.exit_code in (0, 130)
|
||||
|
||||
@patch("asyncio.run", side_effect=Exception("Collect error"))
|
||||
def test_collect_error(self, mock_run):
|
||||
result = runner.invoke(app, ["collect"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
# ── web command ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TestWebCommand:
|
||||
@patch("pathlib.Path.exists", return_value=False)
|
||||
def test_web_no_dist(self, mock_exists):
|
||||
result = runner.invoke(app, ["web"])
|
||||
assert result.exit_code == 1
|
||||
assert "Frontend build not found" in result.stdout
|
||||
|
||||
@patch("socketserver.TCPServer")
|
||||
@patch("os.chdir")
|
||||
@patch("pathlib.Path.exists", return_value=True)
|
||||
def test_web_success(self, mock_exists, mock_chdir, mock_server):
|
||||
# We need to simulate a KeyboardInterrupt to stop serve_forever
|
||||
mock_server.return_value.__enter__.return_value.serve_forever.side_effect = KeyboardInterrupt
|
||||
result = runner.invoke(app, ["web"])
|
||||
assert result.exit_code == 0
|
||||
assert "Serving DECNET Web Dashboard" in result.stdout
|
||||
|
||||
|
||||
# ── correlate command ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestCorrelateCommand:
|
||||
def test_correlate_no_input(self):
|
||||
with patch("sys.stdin.isatty", return_value=True):
|
||||
result = runner.invoke(app, ["correlate"])
|
||||
if result.exit_code != 0:
|
||||
assert result.exit_code == 1
|
||||
assert "Provide --log-file" in result.stdout
|
||||
|
||||
def test_correlate_with_file(self, tmp_path):
|
||||
log_file = tmp_path / "test.log"
|
||||
log_file.write_text(
|
||||
"<134>1 2024-01-15T12:00:00+00:00 decky-01 ssh - auth "
|
||||
'[decnet@55555 src_ip="10.0.0.5" username="admin"] login\n'
|
||||
)
|
||||
result = runner.invoke(app, ["correlate", "--log-file", str(log_file)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
# ── api command ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TestApiCommand:
|
||||
@patch("subprocess.run", side_effect=KeyboardInterrupt)
|
||||
def test_api_keyboard_interrupt(self, mock_run):
|
||||
result = runner.invoke(app, ["api"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("subprocess.run", side_effect=FileNotFoundError)
|
||||
def test_api_not_found(self, mock_run):
|
||||
result = runner.invoke(app, ["api"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
# ── _kill_api ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestKillApi:
|
||||
@patch("os.kill")
|
||||
@patch("psutil.process_iter")
|
||||
def test_kills_matching_processes(self, mock_iter, mock_kill):
|
||||
from decnet.cli import _kill_api
|
||||
mock_uvicorn = MagicMock()
|
||||
mock_uvicorn.info = {
|
||||
"pid": 111, "name": "python",
|
||||
"cmdline": ["python", "-m", "uvicorn", "decnet.web.api:app"],
|
||||
}
|
||||
mock_mutate = MagicMock()
|
||||
mock_mutate.info = {
|
||||
"pid": 222, "name": "python",
|
||||
"cmdline": ["python", "decnet.cli", "mutate", "--watch"],
|
||||
}
|
||||
mock_iter.return_value = [mock_uvicorn, mock_mutate]
|
||||
_kill_api()
|
||||
assert mock_kill.call_count == 2
|
||||
|
||||
@patch("psutil.process_iter")
|
||||
def test_no_matching_processes(self, mock_iter):
|
||||
from decnet.cli import _kill_api
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.info = {"pid": 1, "name": "bash", "cmdline": ["bash"]}
|
||||
mock_iter.return_value = [mock_proc]
|
||||
_kill_api()
|
||||
|
||||
@patch("psutil.process_iter")
|
||||
def test_handles_empty_cmdline(self, mock_iter):
|
||||
from decnet.cli import _kill_api
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.info = {"pid": 1, "name": "bash", "cmdline": None}
|
||||
mock_iter.return_value = [mock_proc]
|
||||
_kill_api()
|
||||
@@ -1,9 +1,16 @@
|
||||
"""Tests for the host-side Docker log collector."""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import patch, MagicMock
|
||||
from decnet.collector import parse_rfc5424, is_service_container, is_service_event
|
||||
from decnet.collector.worker import (
|
||||
_stream_container,
|
||||
_load_service_container_names,
|
||||
log_collector_worker
|
||||
)
|
||||
|
||||
_KNOWN_NAMES = {"omega-decky-http", "omega-decky-smtp", "relay-decky-ftp"}
|
||||
|
||||
@@ -50,6 +57,21 @@ class TestParseRfc5424:
|
||||
result = parse_rfc5424(line)
|
||||
assert result["attacker_ip"] == "10.0.0.5"
|
||||
|
||||
def test_extracts_attacker_ip_from_client_ip(self):
|
||||
line = self._make_line('client_ip="10.0.0.7"')
|
||||
result = parse_rfc5424(line)
|
||||
assert result["attacker_ip"] == "10.0.0.7"
|
||||
|
||||
def test_extracts_attacker_ip_from_remote_ip(self):
|
||||
line = self._make_line('remote_ip="10.0.0.8"')
|
||||
result = parse_rfc5424(line)
|
||||
assert result["attacker_ip"] == "10.0.0.8"
|
||||
|
||||
def test_extracts_attacker_ip_from_ip(self):
|
||||
line = self._make_line('ip="10.0.0.9"')
|
||||
result = parse_rfc5424(line)
|
||||
assert result["attacker_ip"] == "10.0.0.9"
|
||||
|
||||
def test_attacker_ip_defaults_to_unknown(self):
|
||||
line = self._make_line('user="admin"')
|
||||
result = parse_rfc5424(line)
|
||||
@@ -88,6 +110,26 @@ class TestParseRfc5424:
|
||||
# Should not raise
|
||||
json.dumps(result)
|
||||
|
||||
def test_invalid_timestamp_preserved_as_is(self):
|
||||
line = "<134>1 not-a-date decky-01 http - request -"
|
||||
result = parse_rfc5424(line)
|
||||
assert result is not None
|
||||
assert result["timestamp"] == "not-a-date"
|
||||
|
||||
def test_sd_rest_is_plain_text(self):
|
||||
# When SD starts with neither '-' nor '[', treat as msg
|
||||
line = "<134>1 2024-01-15T12:00:00+00:00 decky-01 http - request hello world"
|
||||
result = parse_rfc5424(line)
|
||||
assert result is not None
|
||||
assert result["msg"] == "hello world"
|
||||
|
||||
def test_sd_with_msg_after_bracket(self):
|
||||
line = '<134>1 2024-01-15T12:00:00+00:00 decky-01 http - request [decnet@55555 src_ip="1.2.3.4"] login attempt'
|
||||
result = parse_rfc5424(line)
|
||||
assert result is not None
|
||||
assert result["fields"]["src_ip"] == "1.2.3.4"
|
||||
assert result["msg"] == "login attempt"
|
||||
|
||||
|
||||
class TestIsServiceContainer:
|
||||
def test_known_container_returns_true(self):
|
||||
@@ -113,6 +155,12 @@ class TestIsServiceContainer:
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=set()):
|
||||
assert is_service_container(_make_container("omega-decky-http")) is False
|
||||
|
||||
def test_string_argument(self):
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
assert is_service_container("omega-decky-http") is True
|
||||
assert is_service_container("/omega-decky-http") is True
|
||||
assert is_service_container("nginx") is False
|
||||
|
||||
|
||||
class TestIsServiceEvent:
|
||||
def test_known_service_event_returns_true(self):
|
||||
@@ -130,3 +178,171 @@ class TestIsServiceEvent:
|
||||
def test_no_state_returns_false(self):
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=set()):
|
||||
assert is_service_event({"name": "omega-decky-smtp"}) is False
|
||||
|
||||
def test_strips_leading_slash(self):
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
assert is_service_event({"name": "/omega-decky-smtp"}) is True
|
||||
|
||||
def test_empty_name(self):
|
||||
with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES):
|
||||
assert is_service_event({"name": ""}) is False
|
||||
assert is_service_event({}) is False
|
||||
|
||||
|
||||
class TestLoadServiceContainerNames:
|
||||
def test_with_valid_state(self, tmp_path, monkeypatch):
|
||||
import decnet.config
|
||||
from decnet.config import DeckyConfig, DecnetConfig
|
||||
state_file = tmp_path / "state.json"
|
||||
config = DecnetConfig(
|
||||
mode="unihost", interface="eth0", subnet="192.168.1.0/24",
|
||||
gateway="192.168.1.1",
|
||||
deckies=[
|
||||
DeckyConfig(name="decky-01", ip="192.168.1.10", services=["ssh", "http"],
|
||||
distro="debian", base_image="debian", hostname="test",
|
||||
build_base="debian:bookworm-slim"),
|
||||
],
|
||||
)
|
||||
state_file.write_text(json.dumps({
|
||||
"config": config.model_dump(),
|
||||
"compose_path": "test.yml",
|
||||
}))
|
||||
monkeypatch.setattr(decnet.config, "STATE_FILE", state_file)
|
||||
names = _load_service_container_names()
|
||||
assert names == {"decky-01-ssh", "decky-01-http"}
|
||||
|
||||
def test_no_state(self, tmp_path, monkeypatch):
|
||||
import decnet.config
|
||||
state_file = tmp_path / "nonexistent.json"
|
||||
monkeypatch.setattr(decnet.config, "STATE_FILE", state_file)
|
||||
names = _load_service_container_names()
|
||||
assert names == set()
|
||||
|
||||
|
||||
class TestStreamContainer:
|
||||
def test_streams_rfc5424_lines(self, tmp_path):
|
||||
log_path = tmp_path / "test.log"
|
||||
json_path = tmp_path / "test.json"
|
||||
|
||||
mock_container = MagicMock()
|
||||
rfc_line = '<134>1 2024-01-15T12:00:00+00:00 decky-01 ssh - auth [decnet@55555 src_ip="1.2.3.4"] login\n'
|
||||
mock_container.logs.return_value = [rfc_line.encode("utf-8")]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.containers.get.return_value = mock_container
|
||||
|
||||
with patch("docker.from_env", return_value=mock_client):
|
||||
_stream_container("test-id", log_path, json_path)
|
||||
|
||||
assert log_path.exists()
|
||||
log_content = log_path.read_text()
|
||||
assert "decky-01" in log_content
|
||||
|
||||
assert json_path.exists()
|
||||
json_content = json_path.read_text().strip()
|
||||
parsed = json.loads(json_content)
|
||||
assert parsed["service"] == "ssh"
|
||||
|
||||
def test_handles_non_rfc5424_lines(self, tmp_path):
|
||||
log_path = tmp_path / "test.log"
|
||||
json_path = tmp_path / "test.json"
|
||||
|
||||
mock_container = MagicMock()
|
||||
mock_container.logs.return_value = [b"just a plain log line\n"]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.containers.get.return_value = mock_container
|
||||
|
||||
with patch("docker.from_env", return_value=mock_client):
|
||||
_stream_container("test-id", log_path, json_path)
|
||||
|
||||
assert log_path.exists()
|
||||
assert json_path.read_text() == "" # No JSON written for non-RFC lines
|
||||
|
||||
def test_handles_docker_error(self, tmp_path):
|
||||
log_path = tmp_path / "test.log"
|
||||
json_path = tmp_path / "test.json"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.containers.get.side_effect = Exception("Container not found")
|
||||
|
||||
with patch("docker.from_env", return_value=mock_client):
|
||||
_stream_container("bad-id", log_path, json_path)
|
||||
|
||||
# Should not raise, just log the error
|
||||
|
||||
def test_skips_empty_lines(self, tmp_path):
|
||||
log_path = tmp_path / "test.log"
|
||||
json_path = tmp_path / "test.json"
|
||||
|
||||
mock_container = MagicMock()
|
||||
mock_container.logs.return_value = [b"\n\n\n"]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.containers.get.return_value = mock_container
|
||||
|
||||
with patch("docker.from_env", return_value=mock_client):
|
||||
_stream_container("test-id", log_path, json_path)
|
||||
|
||||
assert log_path.read_text() == ""
|
||||
|
||||
|
||||
class TestLogCollectorWorker:
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_initial_discovery(self, tmp_path):
|
||||
log_file = str(tmp_path / "decnet.log")
|
||||
|
||||
mock_container = MagicMock()
|
||||
mock_container.id = "c1"
|
||||
mock_container.name = "/s-1"
|
||||
# Mock labels to satisfy is_service_container
|
||||
mock_container.labels = {"com.docker.compose.project": "decnet"}
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.containers.list.return_value = [mock_container]
|
||||
# Make events return an empty generator/iterator immediately
|
||||
mock_client.events.return_value = iter([])
|
||||
|
||||
with patch("docker.from_env", return_value=mock_client), \
|
||||
patch("decnet.collector.worker.is_service_container", return_value=True):
|
||||
# Run with a short task timeout because it loops
|
||||
try:
|
||||
await asyncio.wait_for(log_collector_worker(log_file), timeout=0.1)
|
||||
except (asyncio.TimeoutError, StopIteration):
|
||||
pass
|
||||
|
||||
# Should have tried to list and watch events
|
||||
mock_client.containers.list.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_handles_events(self, tmp_path):
|
||||
log_file = str(tmp_path / "decnet.log")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.containers.list.return_value = []
|
||||
|
||||
event = {
|
||||
"id": "c2",
|
||||
"Actor": {"Attributes": {"name": "s-2", "com.docker.compose.project": "decnet"}}
|
||||
}
|
||||
mock_client.events.return_value = iter([event])
|
||||
|
||||
with patch("docker.from_env", return_value=mock_client), \
|
||||
patch("decnet.collector.worker.is_service_event", return_value=True):
|
||||
try:
|
||||
await asyncio.wait_for(log_collector_worker(log_file), timeout=0.1)
|
||||
except (asyncio.TimeoutError, StopIteration):
|
||||
pass
|
||||
|
||||
mock_client.events.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_exception_handling(self, tmp_path):
|
||||
log_file = str(tmp_path / "decnet.log")
|
||||
mock_client = MagicMock()
|
||||
mock_client.containers.list.side_effect = Exception("Docker down")
|
||||
|
||||
with patch("docker.from_env", return_value=mock_client):
|
||||
# Should not raise
|
||||
await log_collector_worker(log_file)
|
||||
|
||||
|
||||
308
tests/test_deployer.py
Normal file
308
tests/test_deployer.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Tests for decnet/engine/deployer.py
|
||||
|
||||
Covers _compose, _compose_with_retry, _sync_logging_helper,
|
||||
deploy (dry-run and mocked), teardown, status, and _print_status.
|
||||
All Docker and subprocess calls are mocked.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.config import DeckyConfig, DecnetConfig
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _decky(name: str = "decky-01", ip: str = "192.168.1.10",
|
||||
services: list[str] | None = None) -> DeckyConfig:
|
||||
return DeckyConfig(
|
||||
name=name, ip=ip, services=services or ["ssh"],
|
||||
distro="debian", base_image="debian", hostname="test-host",
|
||||
build_base="debian:bookworm-slim", nmap_os="linux",
|
||||
)
|
||||
|
||||
|
||||
def _config(deckies: list[DeckyConfig] | None = None, ipvlan: bool = False) -> DecnetConfig:
|
||||
return DecnetConfig(
|
||||
mode="unihost", interface="eth0", subnet="192.168.1.0/24",
|
||||
gateway="192.168.1.1", deckies=deckies or [_decky()],
|
||||
ipvlan=ipvlan,
|
||||
)
|
||||
|
||||
|
||||
# ── _compose ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCompose:
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_compose_constructs_correct_command(self, mock_run):
|
||||
from decnet.engine.deployer import _compose
|
||||
_compose("up", "-d", compose_file=Path("test.yml"))
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[:4] == ["docker", "compose", "-f", "test.yml"]
|
||||
assert "up" in cmd
|
||||
assert "-d" in cmd
|
||||
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_compose_passes_env(self, mock_run):
|
||||
from decnet.engine.deployer import _compose
|
||||
_compose("build", env={"DOCKER_BUILDKIT": "1"})
|
||||
_, kwargs = mock_run.call_args
|
||||
assert "DOCKER_BUILDKIT" in kwargs["env"]
|
||||
|
||||
|
||||
# ── _compose_with_retry ───────────────────────────────────────────────────────
|
||||
|
||||
class TestComposeWithRetry:
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_success_first_try(self, mock_run):
|
||||
from decnet.engine.deployer import _compose_with_retry
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
_compose_with_retry("up", "-d") # should not raise
|
||||
|
||||
@patch("decnet.engine.deployer.time.sleep")
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_transient_failure_retries(self, mock_run, mock_sleep):
|
||||
from decnet.engine.deployer import _compose_with_retry
|
||||
fail_result = MagicMock(returncode=1, stdout="", stderr="temporary error")
|
||||
ok_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
mock_run.side_effect = [fail_result, ok_result]
|
||||
_compose_with_retry("up", retries=3)
|
||||
assert mock_run.call_count == 2
|
||||
mock_sleep.assert_called_once()
|
||||
|
||||
@patch("decnet.engine.deployer.time.sleep")
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_permanent_error_no_retry(self, mock_run, mock_sleep):
|
||||
from decnet.engine.deployer import _compose_with_retry
|
||||
fail_result = MagicMock(returncode=1, stdout="", stderr="manifest unknown error")
|
||||
mock_run.return_value = fail_result
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_compose_with_retry("pull", retries=3)
|
||||
assert mock_run.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("decnet.engine.deployer.time.sleep")
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_max_retries_exhausted(self, mock_run, mock_sleep):
|
||||
from decnet.engine.deployer import _compose_with_retry
|
||||
fail_result = MagicMock(returncode=1, stdout="", stderr="connection refused")
|
||||
mock_run.return_value = fail_result
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_compose_with_retry("up", retries=2)
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_stdout_printed_on_success(self, mock_run, capsys):
|
||||
from decnet.engine.deployer import _compose_with_retry
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="done\n", stderr="")
|
||||
_compose_with_retry("build")
|
||||
captured = capsys.readouterr()
|
||||
assert "done" in captured.out
|
||||
|
||||
|
||||
# ── _sync_logging_helper ─────────────────────────────────────────────────────
|
||||
|
||||
class TestSyncLoggingHelper:
|
||||
@patch("decnet.engine.deployer.shutil.copy2")
|
||||
@patch("decnet.engine.deployer._CANONICAL_LOGGING")
|
||||
def test_copies_when_file_differs(self, mock_canonical, mock_copy):
|
||||
from decnet.engine.deployer import _sync_logging_helper
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.dockerfile_context.return_value = Path("/tmp/test_ctx")
|
||||
mock_canonical.__truediv__ = Path.__truediv__
|
||||
|
||||
with patch("decnet.services.registry.get_service", return_value=mock_svc):
|
||||
with patch("pathlib.Path.exists", return_value=False):
|
||||
config = _config()
|
||||
_sync_logging_helper(config)
|
||||
|
||||
|
||||
# ── deploy ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDeploy:
|
||||
@patch("decnet.engine.deployer._print_status")
|
||||
@patch("decnet.engine.deployer._compose_with_retry")
|
||||
@patch("decnet.engine.deployer.save_state")
|
||||
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
|
||||
@patch("decnet.engine.deployer._sync_logging_helper")
|
||||
@patch("decnet.engine.deployer.setup_host_macvlan")
|
||||
@patch("decnet.engine.deployer.create_macvlan_network")
|
||||
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
def test_dry_run_no_containers(self, mock_docker, mock_range, mock_hip,
|
||||
mock_create, mock_setup, mock_sync,
|
||||
mock_compose, mock_save, mock_retry, mock_print):
|
||||
from decnet.engine.deployer import deploy
|
||||
config = _config()
|
||||
deploy(config, dry_run=True)
|
||||
mock_create.assert_not_called()
|
||||
mock_retry.assert_not_called()
|
||||
mock_save.assert_not_called()
|
||||
|
||||
@patch("decnet.engine.deployer._print_status")
|
||||
@patch("decnet.engine.deployer._compose_with_retry")
|
||||
@patch("decnet.engine.deployer.save_state")
|
||||
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
|
||||
@patch("decnet.engine.deployer._sync_logging_helper")
|
||||
@patch("decnet.engine.deployer.setup_host_macvlan")
|
||||
@patch("decnet.engine.deployer.create_macvlan_network")
|
||||
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
def test_macvlan_deploy(self, mock_docker, mock_range, mock_hip,
|
||||
mock_create, mock_setup, mock_sync,
|
||||
mock_compose, mock_save, mock_retry, mock_print):
|
||||
from decnet.engine.deployer import deploy
|
||||
config = _config(ipvlan=False)
|
||||
deploy(config)
|
||||
mock_create.assert_called_once()
|
||||
mock_setup.assert_called_once()
|
||||
mock_save.assert_called_once()
|
||||
mock_retry.assert_called()
|
||||
|
||||
@patch("decnet.engine.deployer._print_status")
|
||||
@patch("decnet.engine.deployer._compose_with_retry")
|
||||
@patch("decnet.engine.deployer.save_state")
|
||||
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
|
||||
@patch("decnet.engine.deployer._sync_logging_helper")
|
||||
@patch("decnet.engine.deployer.setup_host_ipvlan")
|
||||
@patch("decnet.engine.deployer.create_ipvlan_network")
|
||||
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
def test_ipvlan_deploy(self, mock_docker, mock_range, mock_hip,
|
||||
mock_create, mock_setup, mock_sync,
|
||||
mock_compose, mock_save, mock_retry, mock_print):
|
||||
from decnet.engine.deployer import deploy
|
||||
config = _config(ipvlan=True)
|
||||
deploy(config)
|
||||
mock_create.assert_called_once()
|
||||
mock_setup.assert_called_once()
|
||||
|
||||
@patch("decnet.engine.deployer._print_status")
|
||||
@patch("decnet.engine.deployer._compose_with_retry")
|
||||
@patch("decnet.engine.deployer.save_state")
|
||||
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
|
||||
@patch("decnet.engine.deployer._sync_logging_helper")
|
||||
@patch("decnet.engine.deployer.setup_host_macvlan")
|
||||
@patch("decnet.engine.deployer.create_macvlan_network")
|
||||
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
def test_parallel_build(self, mock_docker, mock_range, mock_hip,
|
||||
mock_create, mock_setup, mock_sync,
|
||||
mock_compose, mock_save, mock_retry, mock_print):
|
||||
from decnet.engine.deployer import deploy
|
||||
config = _config()
|
||||
deploy(config, parallel=True)
|
||||
# Parallel mode calls _compose_with_retry for "build" and "up" separately
|
||||
calls = mock_retry.call_args_list
|
||||
assert any("build" in str(c) for c in calls)
|
||||
|
||||
@patch("decnet.engine.deployer._print_status")
|
||||
@patch("decnet.engine.deployer._compose_with_retry")
|
||||
@patch("decnet.engine.deployer.save_state")
|
||||
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
|
||||
@patch("decnet.engine.deployer._sync_logging_helper")
|
||||
@patch("decnet.engine.deployer.setup_host_macvlan")
|
||||
@patch("decnet.engine.deployer.create_macvlan_network")
|
||||
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
def test_no_cache_build(self, mock_docker, mock_range, mock_hip,
|
||||
mock_create, mock_setup, mock_sync,
|
||||
mock_compose, mock_save, mock_retry, mock_print):
|
||||
from decnet.engine.deployer import deploy
|
||||
config = _config()
|
||||
deploy(config, no_cache=True)
|
||||
calls = mock_retry.call_args_list
|
||||
assert any("--no-cache" in str(c) for c in calls)
|
||||
|
||||
|
||||
# ── teardown ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestTeardown:
|
||||
@patch("decnet.engine.deployer.load_state", return_value=None)
|
||||
def test_no_state(self, mock_load):
|
||||
from decnet.engine.deployer import teardown
|
||||
teardown() # should not raise
|
||||
|
||||
@patch("decnet.engine.deployer.clear_state")
|
||||
@patch("decnet.engine.deployer.remove_macvlan_network")
|
||||
@patch("decnet.engine.deployer.teardown_host_macvlan")
|
||||
@patch("decnet.engine.deployer._compose")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
@patch("decnet.engine.deployer.load_state")
|
||||
def test_full_teardown_macvlan(self, mock_load, mock_docker, mock_range,
|
||||
mock_compose, mock_td_macvlan, mock_rm_net,
|
||||
mock_clear):
|
||||
config = _config()
|
||||
mock_load.return_value = (config, Path("test.yml"))
|
||||
from decnet.engine.deployer import teardown
|
||||
teardown()
|
||||
mock_compose.assert_called_once()
|
||||
mock_td_macvlan.assert_called_once()
|
||||
mock_rm_net.assert_called_once()
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
@patch("decnet.engine.deployer.clear_state")
|
||||
@patch("decnet.engine.deployer.remove_macvlan_network")
|
||||
@patch("decnet.engine.deployer.teardown_host_ipvlan")
|
||||
@patch("decnet.engine.deployer._compose")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
@patch("decnet.engine.deployer.load_state")
|
||||
def test_full_teardown_ipvlan(self, mock_load, mock_docker, mock_range,
|
||||
mock_compose, mock_td_ipvlan, mock_rm_net,
|
||||
mock_clear):
|
||||
config = _config(ipvlan=True)
|
||||
mock_load.return_value = (config, Path("test.yml"))
|
||||
from decnet.engine.deployer import teardown
|
||||
teardown()
|
||||
mock_td_ipvlan.assert_called_once()
|
||||
|
||||
|
||||
# ── status ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestStatus:
|
||||
@patch("decnet.engine.deployer.load_state", return_value=None)
|
||||
def test_no_state(self, mock_load):
|
||||
from decnet.engine.deployer import status
|
||||
status() # should not raise
|
||||
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
@patch("decnet.engine.deployer.load_state")
|
||||
def test_with_running_containers(self, mock_load, mock_docker):
|
||||
config = _config()
|
||||
mock_load.return_value = (config, Path("test.yml"))
|
||||
mock_container = MagicMock()
|
||||
mock_container.name = "decky-01-ssh"
|
||||
mock_container.status = "running"
|
||||
mock_docker.return_value.containers.list.return_value = [mock_container]
|
||||
from decnet.engine.deployer import status
|
||||
status() # should not raise
|
||||
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
@patch("decnet.engine.deployer.load_state")
|
||||
def test_with_absent_containers(self, mock_load, mock_docker):
|
||||
config = _config()
|
||||
mock_load.return_value = (config, Path("test.yml"))
|
||||
mock_docker.return_value.containers.list.return_value = []
|
||||
from decnet.engine.deployer import status
|
||||
status() # should not raise
|
||||
|
||||
|
||||
# ── _print_status ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestPrintStatus:
|
||||
def test_renders_table(self):
|
||||
from decnet.engine.deployer import _print_status
|
||||
config = _config(deckies=[_decky(), _decky("decky-02", "192.168.1.11")])
|
||||
_print_status(config) # should not raise
|
||||
191
tests/test_fleet.py
Normal file
191
tests/test_fleet.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Tests for decnet/fleet.py — fleet builder logic.
|
||||
|
||||
Covers build_deckies, build_deckies_from_ini, resolve_distros,
|
||||
and edge cases like IP exhaustion and missing services.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.archetypes import get_archetype
|
||||
from decnet.fleet import (
|
||||
build_deckies,
|
||||
build_deckies_from_ini,
|
||||
resolve_distros,
|
||||
)
|
||||
from decnet.ini_loader import IniConfig, DeckySpec
|
||||
|
||||
|
||||
# ── resolve_distros ───────────────────────────────────────────────────────────
|
||||
|
||||
class TestResolveDistros:
|
||||
def test_explicit_distros_cycled(self):
|
||||
result = resolve_distros(["debian", "ubuntu22"], False, 5)
|
||||
assert result == ["debian", "ubuntu22", "debian", "ubuntu22", "debian"]
|
||||
|
||||
def test_explicit_single_distro(self):
|
||||
result = resolve_distros(["rocky9"], False, 3)
|
||||
assert result == ["rocky9", "rocky9", "rocky9"]
|
||||
|
||||
def test_randomize_returns_correct_count(self):
|
||||
result = resolve_distros(None, True, 4)
|
||||
assert len(result) == 4
|
||||
# All returned slugs should be valid distro slugs
|
||||
from decnet.distros import all_distros
|
||||
valid = set(all_distros().keys())
|
||||
for slug in result:
|
||||
assert slug in valid
|
||||
|
||||
def test_archetype_preferred_distros(self):
|
||||
arch = get_archetype("deaddeck")
|
||||
result = resolve_distros(None, False, 3, archetype=arch)
|
||||
for slug in result:
|
||||
assert slug in arch.preferred_distros
|
||||
|
||||
def test_fallback_cycles_all_distros(self):
|
||||
result = resolve_distros(None, False, 2)
|
||||
from decnet.distros import all_distros
|
||||
slugs = list(all_distros().keys())
|
||||
assert result[0] == slugs[0]
|
||||
assert result[1] == slugs[1]
|
||||
|
||||
|
||||
# ── build_deckies ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestBuildDeckies:
|
||||
_IPS: list[str] = ["192.168.1.10", "192.168.1.11", "192.168.1.12"]
|
||||
|
||||
def test_explicit_services(self):
|
||||
deckies = build_deckies(3, self._IPS, ["ssh", "http"], False)
|
||||
assert len(deckies) == 3
|
||||
for decky in deckies:
|
||||
assert decky.services == ["ssh", "http"]
|
||||
|
||||
def test_archetype_services(self):
|
||||
arch = get_archetype("deaddeck")
|
||||
deckies = build_deckies(2, self._IPS[:2], None, False, archetype=arch)
|
||||
assert len(deckies) == 2
|
||||
for decky in deckies:
|
||||
assert set(decky.services) == set(arch.services)
|
||||
assert decky.archetype == "deaddeck"
|
||||
assert decky.nmap_os == arch.nmap_os
|
||||
|
||||
def test_randomize_services(self):
|
||||
deckies = build_deckies(3, self._IPS, None, True)
|
||||
assert len(deckies) == 3
|
||||
for decky in deckies:
|
||||
assert len(decky.services) >= 1
|
||||
|
||||
def test_no_services_raises(self):
|
||||
with pytest.raises(ValueError, match="Provide services_explicit"):
|
||||
build_deckies(1, self._IPS[:1], None, False)
|
||||
|
||||
def test_names_sequential(self):
|
||||
deckies = build_deckies(3, self._IPS, ["ssh"], False)
|
||||
assert [d.name for d in deckies] == ["decky-01", "decky-02", "decky-03"]
|
||||
|
||||
def test_ips_assigned_correctly(self):
|
||||
deckies = build_deckies(3, self._IPS, ["ssh"], False)
|
||||
assert [d.ip for d in deckies] == self._IPS
|
||||
|
||||
def test_mutate_interval_propagated(self):
|
||||
deckies = build_deckies(1, self._IPS[:1], ["ssh"], False, mutate_interval=15)
|
||||
assert deckies[0].mutate_interval == 15
|
||||
|
||||
def test_distros_explicit(self):
|
||||
deckies = build_deckies(2, self._IPS[:2], ["ssh"], False, distros_explicit=["rocky9"])
|
||||
for decky in deckies:
|
||||
assert decky.distro == "rocky9"
|
||||
|
||||
def test_randomize_distros(self):
|
||||
deckies = build_deckies(2, self._IPS[:2], ["ssh"], False, randomize_distros=True)
|
||||
from decnet.distros import all_distros
|
||||
valid = set(all_distros().keys())
|
||||
for decky in deckies:
|
||||
assert decky.distro in valid
|
||||
|
||||
|
||||
# ── build_deckies_from_ini ────────────────────────────────────────────────────
|
||||
|
||||
class TestBuildDeckiesFromIni:
|
||||
_SUBNET: str = "192.168.1.0/24"
|
||||
_GATEWAY: str = "192.168.1.1"
|
||||
_HOST_IP: str = "192.168.1.2"
|
||||
|
||||
def _make_ini(self, deckies: list[DeckySpec], **kwargs) -> IniConfig:
|
||||
defaults: dict = {
|
||||
"interface": "eth0",
|
||||
"subnet": None,
|
||||
"gateway": None,
|
||||
"mutate_interval": None,
|
||||
"custom_services": [],
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return IniConfig(deckies=deckies, **defaults)
|
||||
|
||||
def test_explicit_ip(self):
|
||||
spec = DeckySpec(name="test-1", ip="192.168.1.50", services=["ssh"])
|
||||
ini = self._make_ini([spec])
|
||||
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
|
||||
assert len(deckies) == 1
|
||||
assert deckies[0].ip == "192.168.1.50"
|
||||
|
||||
def test_auto_ip_allocation(self):
|
||||
spec = DeckySpec(name="test-1", services=["ssh"])
|
||||
ini = self._make_ini([spec])
|
||||
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
|
||||
assert len(deckies) == 1
|
||||
assert deckies[0].ip not in (self._GATEWAY, self._HOST_IP, "192.168.1.0", "192.168.1.255")
|
||||
|
||||
def test_archetype_services(self):
|
||||
spec = DeckySpec(name="test-1", archetype="deaddeck")
|
||||
ini = self._make_ini([spec])
|
||||
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
|
||||
arch = get_archetype("deaddeck")
|
||||
assert set(deckies[0].services) == set(arch.services)
|
||||
|
||||
def test_randomize_services(self):
|
||||
spec = DeckySpec(name="test-1")
|
||||
ini = self._make_ini([spec])
|
||||
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, True)
|
||||
assert len(deckies[0].services) >= 1
|
||||
|
||||
def test_no_services_no_arch_no_randomize_raises(self):
|
||||
spec = DeckySpec(name="test-1")
|
||||
ini = self._make_ini([spec])
|
||||
with pytest.raises(ValueError, match="has no services"):
|
||||
build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
|
||||
|
||||
def test_unknown_service_raises(self):
|
||||
spec = DeckySpec(name="test-1", services=["nonexistent_svc_xyz"])
|
||||
ini = self._make_ini([spec])
|
||||
with pytest.raises(ValueError, match="Unknown service"):
|
||||
build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
|
||||
|
||||
def test_mutate_interval_from_cli(self):
|
||||
spec = DeckySpec(name="test-1", services=["ssh"])
|
||||
ini = self._make_ini([spec])
|
||||
deckies = build_deckies_from_ini(
|
||||
ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False, cli_mutate_interval=42
|
||||
)
|
||||
assert deckies[0].mutate_interval == 42
|
||||
|
||||
def test_mutate_interval_from_ini(self):
|
||||
spec = DeckySpec(name="test-1", services=["ssh"])
|
||||
ini = self._make_ini([spec], mutate_interval=99)
|
||||
deckies = build_deckies_from_ini(
|
||||
ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False, cli_mutate_interval=None
|
||||
)
|
||||
assert deckies[0].mutate_interval == 99
|
||||
|
||||
def test_nmap_os_from_spec(self):
|
||||
spec = DeckySpec(name="test-1", services=["ssh"], nmap_os="windows")
|
||||
ini = self._make_ini([spec])
|
||||
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
|
||||
assert deckies[0].nmap_os == "windows"
|
||||
|
||||
def test_nmap_os_from_archetype(self):
|
||||
spec = DeckySpec(name="test-1", archetype="deaddeck")
|
||||
ini = self._make_ini([spec])
|
||||
deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False)
|
||||
assert deckies[0].nmap_os == "linux"
|
||||
217
tests/test_ingester.py
Normal file
217
tests/test_ingester.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Tests for decnet/web/ingester.py
|
||||
|
||||
Covers log_ingestion_worker and _extract_bounty with
|
||||
async tests using temporary files.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── _extract_bounty ───────────────────────────────────────────────────────────
|
||||
|
||||
class TestExtractBounty:
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_extraction(self):
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
log_data: dict = {
|
||||
"decky": "decky-01",
|
||||
"service": "ssh",
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"fields": {"username": "admin", "password": "hunter2"},
|
||||
}
|
||||
await _extract_bounty(mock_repo, log_data)
|
||||
mock_repo.add_bounty.assert_awaited_once()
|
||||
bounty = mock_repo.add_bounty.call_args[0][0]
|
||||
assert bounty["bounty_type"] == "credential"
|
||||
assert bounty["payload"]["username"] == "admin"
|
||||
assert bounty["payload"]["password"] == "hunter2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_fields_skips(self):
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
await _extract_bounty(mock_repo, {"decky": "x"})
|
||||
mock_repo.add_bounty.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fields_not_dict_skips(self):
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
await _extract_bounty(mock_repo, {"fields": "not-a-dict"})
|
||||
mock_repo.add_bounty.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_password_skips(self):
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
await _extract_bounty(mock_repo, {"fields": {"username": "admin"}})
|
||||
mock_repo.add_bounty.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_username_skips(self):
|
||||
from decnet.web.ingester import _extract_bounty
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_bounty = AsyncMock()
|
||||
await _extract_bounty(mock_repo, {"fields": {"password": "pass"}})
|
||||
mock_repo.add_bounty.assert_not_awaited()
|
||||
|
||||
|
||||
# ── log_ingestion_worker ──────────────────────────────────────────────────────
|
||||
|
||||
class TestLogIngestionWorker:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_env_var_returns_immediately(self):
|
||||
from decnet.web.ingester import log_ingestion_worker
|
||||
mock_repo = MagicMock()
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
# Remove DECNET_INGEST_LOG_FILE if set
|
||||
os.environ.pop("DECNET_INGEST_LOG_FILE", None)
|
||||
await log_ingestion_worker(mock_repo)
|
||||
# Should return immediately without error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_not_exists_waits(self, tmp_path):
|
||||
from decnet.web.ingester import log_ingestion_worker
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.add_log = AsyncMock()
|
||||
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_log.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_bounty = 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_log.assert_awaited_once()
|
||||
|
||||
@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_bounty = 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_log.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_bounty = 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
|
||||
assert mock_repo.add_log.await_count >= 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_bounty = 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_log.assert_not_awaited()
|
||||
28
tests/test_smtp_relay.py
Normal file
28
tests/test_smtp_relay.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Tests for SMTP Relay service.
|
||||
"""
|
||||
|
||||
from decnet.services.smtp_relay import SMTPRelayService
|
||||
|
||||
def test_smtp_relay_compose_fragment():
|
||||
svc = SMTPRelayService()
|
||||
fragment = svc.compose_fragment("test-decky", log_target="log-server")
|
||||
|
||||
assert fragment["container_name"] == "test-decky-smtp_relay"
|
||||
assert fragment["environment"]["SMTP_OPEN_RELAY"] == "1"
|
||||
assert fragment["environment"]["LOG_TARGET"] == "log-server"
|
||||
|
||||
def test_smtp_relay_custom_cfg():
|
||||
svc = SMTPRelayService()
|
||||
fragment = svc.compose_fragment(
|
||||
"test-decky",
|
||||
service_cfg={"banner": "Welcome", "mta": "Postfix"}
|
||||
)
|
||||
assert fragment["environment"]["SMTP_BANNER"] == "Welcome"
|
||||
assert fragment["environment"]["SMTP_MTA"] == "Postfix"
|
||||
|
||||
def test_smtp_relay_dockerfile_context():
|
||||
svc = SMTPRelayService()
|
||||
ctx = svc.dockerfile_context()
|
||||
assert ctx.name == "smtp"
|
||||
assert ctx.is_dir()
|
||||
@@ -112,8 +112,8 @@ def test_dockerfile_has_rsyslog():
|
||||
|
||||
|
||||
def test_dockerfile_runs_as_root():
|
||||
lines = [l.strip() for l in _dockerfile_text().splitlines()]
|
||||
user_lines = [l for l in lines if l.startswith("USER ")]
|
||||
lines = [line.strip() for line in _dockerfile_text().splitlines()]
|
||||
user_lines = [line for line in lines if line.startswith("USER ")]
|
||||
assert user_lines == [], f"Unexpected USER directive(s): {user_lines}"
|
||||
|
||||
|
||||
|
||||
154
tests/test_web_api.py
Normal file
154
tests/test_web_api.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
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)):
|
||||
async with lifespan(mock_app):
|
||||
mock_repo.initialize.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_db_retry(self):
|
||||
from decnet.web.api import lifespan
|
||||
mock_app = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
_call_count: int = 0
|
||||
|
||||
async def _failing_init():
|
||||
nonlocal _call_count
|
||||
_call_count += 1
|
||||
if _call_count < 3:
|
||||
raise Exception("DB locked")
|
||||
|
||||
mock_repo.initialize = _failing_init
|
||||
|
||||
with patch("decnet.web.api.repo", mock_repo):
|
||||
with patch("decnet.web.api.asyncio.sleep", new_callable=AsyncMock):
|
||||
with patch("decnet.web.api.log_ingestion_worker", return_value=asyncio.sleep(0)):
|
||||
with patch("decnet.web.api.log_collector_worker", return_value=asyncio.sleep(0)):
|
||||
async with lifespan(mock_app):
|
||||
assert _call_count == 3
|
||||
Reference in New Issue
Block a user