Files
DECNET/tests/api/fleet/test_deploy_automode.py
anti 245975a6dd fix(security): close LOW ASVS findings — env bypass, SSE/deployment authz, CN fail-close, password byte-limit, exception leaks, BUG-12..16
Auth/session (V2.1.7, V4.1.5, V4.1.6, V2.1.4/V2.1.5):
- env secret validation no longer bypassed by attacker-injectable PYTEST* env;
  gated on explicit DECNET_TESTING=1 (set only in conftest).
- must_change_password now enforced on the SSE header-JWT path, not just ticket mint.
- GET /system/deployment-mode requires viewer auth (was leaking role + topology size).
- CreateUser/ResetUser passwords min_length=12; passwords >72 bytes rejected
  explicitly instead of bcrypt silently truncating.

Swarm ingestion (V9.1.3, BUG-16):
- Log listener hard-rejects peers with unparseable/empty cert CN (fail closed,
  ingests nothing) instead of tagging 'unknown'.
- Shutdown handlers no longer swallow real errors (narrowed to CancelledError).

Info leakage (V7.1.2, V14.1.2):
- Exception text sanitized on swarm-update, health, tarpit, realism, file-drop,
  blank-topology endpoints (raw tc/docker stderr, DB/Docker errors logged
  server-side, generic detail returned). pyproject license corrected to AGPL-3.0.

Correctness (BUG-12..16):
- BUG-12 atomic credential upsert (UNIQUE constraint + IntegrityError retry,
  consistent principal_key canonicalization).
- BUG-13 rule-tail watermark uses >= with seen-id dedup (no same-second drop).
- BUG-14 worker wake cleared before wait (no lost wake during tick).
- BUG-15 intel gather tolerates an unexpected provider raise.
- BUG-16 see above.

Already-closed (verified, no change): V2.1.6, V5.1.3, V9.1.2. Accept-risk +
documented: V2.1.8 cache window, V3.1.3 idle timeout. Tests added for every fix;
unanimous adversarial review after two refute-fix rounds.
2026-06-10 13:27:14 -04:00

205 lines
7.4 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""POST /deckies/deploy auto-mode: master + swarm hosts → shard to workers."""
from __future__ import annotations
from unittest.mock import patch, AsyncMock
import pytest
from decnet.web.dependencies import repo
from decnet.web.db.models import SwarmDeployResponse, SwarmHostResult
@pytest.fixture(autouse=True)
def contract_test_mode(monkeypatch):
monkeypatch.setenv("DECNET_CONTRACT_TEST", "true")
@pytest.fixture(autouse=True)
def mock_network():
with patch("decnet.web.router.fleet.api_deploy_deckies.get_host_ip", return_value="192.168.1.100"):
with patch("decnet.web.router.fleet.api_deploy_deckies.detect_interface", return_value="eth0"):
with patch("decnet.web.router.fleet.api_deploy_deckies.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1")):
yield
@pytest.mark.anyio
async def test_deploy_automode_unihost_when_no_swarm_hosts(client, auth_token, monkeypatch):
"""No swarm hosts enrolled → local unihost deploy returns 202 with lifecycle ids."""
monkeypatch.setenv("DECNET_MODE", "master")
for row in await repo.list_swarm_hosts():
await repo.delete_swarm_host(row["uuid"])
await repo.set_state("deployment", None)
ini = "[decky-solo]\nservices = ssh\n"
resp = await client.post(
"/api/v1/deckies/deploy",
json={"ini_content": ini},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 202, resp.text
body = resp.json()
assert body["mode"] == "unihost"
assert len(body["lifecycle_ids"]) == 1
@pytest.mark.anyio
async def test_deploy_automode_shards_when_swarm_host_enrolled(client, auth_token, monkeypatch):
"""Master + one active swarm host → swarm mode, lifecycle rows + 202.
The handler no longer awaits dispatch synchronously — it commits the
new shape, creates lifecycle rows, and spawns the runner. We
verify the commit + the per-decky host_uuid assignment via the
committed deployment state, and that 202 carries one lifecycle id
per decky."""
monkeypatch.setenv("DECNET_MODE", "master")
await repo.set_state("deployment", None)
for row in await repo.list_swarm_hosts():
await repo.delete_swarm_host(row["uuid"])
from datetime import datetime, timezone
await repo.add_swarm_host({
"uuid": "host-A",
"name": "worker-a",
"address": "10.0.0.50",
"agent_port": 8765,
"status": "active",
"client_cert_fingerprint": "x" * 64,
"updater_cert_fingerprint": None,
"cert_bundle_path": "/tmp/worker-a",
"enrolled_at": datetime.now(timezone.utc),
"notes": "",
})
ini = "[decky-01]\nservices = ssh\n[decky-02]\nservices = http\n"
resp = await client.post(
"/api/v1/deckies/deploy",
json={"ini_content": ini},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 202, resp.text
body = resp.json()
assert body["mode"] == "swarm"
assert len(body["lifecycle_ids"]) == 2
committed = await repo.get_state("deployment")
assert committed is not None
cfg = committed["config"]
assert cfg["mode"] == "swarm"
assert {d["host_uuid"] for d in cfg["deckies"]} == {"host-A"}
await repo.delete_swarm_host("host-A")
@pytest.mark.anyio
async def test_deploy_automode_resets_stale_host_uuid(client, auth_token, monkeypatch):
"""Deckies carried over from prior state must not be dispatched to a host
uuid that no longer exists — reset + round-robin against live hosts."""
monkeypatch.setenv("DECNET_MODE", "master")
for row in await repo.list_swarm_hosts():
await repo.delete_swarm_host(row["uuid"])
from datetime import datetime, timezone
await repo.add_swarm_host({
"uuid": "host-LIVE",
"name": "live",
"address": "10.0.0.60",
"agent_port": 8765,
"status": "active",
"client_cert_fingerprint": "a" * 64,
"updater_cert_fingerprint": None,
"cert_bundle_path": "/tmp/live",
"enrolled_at": datetime.now(timezone.utc),
"notes": "",
})
# Prior state: decky-old is assigned to a now-decommissioned host.
await repo.set_state("deployment", {
"config": {
"mode": "swarm",
"interface": "eth0",
"subnet": "192.168.1.0/24",
"gateway": "192.168.1.1",
"deckies": [{
"name": "decky-old",
"ip": "192.168.1.50",
"services": ["ssh"],
"distro": "debian",
"base_image": "debian:bookworm-slim",
"hostname": "decky-old",
"host_uuid": "ghost-uuid",
}],
},
"compose_path": "",
})
ini = "[decky-new]\nservices = ssh\n"
resp = await client.post(
"/api/v1/deckies/deploy",
json={"ini_content": ini},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 202, resp.text
committed = await repo.get_state("deployment")
assert committed is not None
cfg = committed["config"]
# The carried-over decky and the new one must both point at the live host.
assert {d["host_uuid"] for d in cfg["deckies"]} == {"host-LIVE"}
await repo.delete_swarm_host("host-LIVE")
await repo.set_state("deployment", None)
@pytest.mark.anyio
async def test_deploy_automode_flips_ipvlan_for_opted_in_host(client, auth_token, monkeypatch):
"""A host enrolled with use_ipvlan=True must receive a DecnetConfig with
ipvlan=True in its shard — sharding is per-host, so _worker_config flips it."""
from decnet.web.router.swarm.api_deploy_swarm import _worker_config
from decnet.config import DecnetConfig, DeckyConfig
base = DecnetConfig(
mode="swarm", interface="eth0", subnet="192.168.1.0/24", gateway="192.168.1.1",
ipvlan=False,
deckies=[DeckyConfig(
name="decky-1", ip="192.168.1.10", services=["ssh"],
distro="debian", base_image="debian:bookworm-slim", hostname="decky-1",
host_uuid="h1",
)],
)
opted_in = {"uuid": "h1", "name": "w1", "use_ipvlan": True}
opted_out = {"uuid": "h1", "name": "w1", "use_ipvlan": False}
assert _worker_config(base, base.deckies, opted_in).ipvlan is True
assert _worker_config(base, base.deckies, opted_out).ipvlan is False
@pytest.mark.anyio
async def test_deployment_mode_endpoint(client, auth_token, monkeypatch):
monkeypatch.setenv("DECNET_MODE", "master")
for row in await repo.list_swarm_hosts():
await repo.delete_swarm_host(row["uuid"])
resp = await client.get(
"/api/v1/system/deployment-mode",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 200
body = resp.json()
assert body["role"] == "master"
assert body["mode"] == "unihost"
assert body["swarm_host_count"] == 0
@pytest.mark.anyio
async def test_deployment_mode_endpoint_requires_auth(client):
# V4.1.6: the response leaks host role + enrolled-worker count, so the
# endpoint must not be reachable without a valid viewer/admin JWT. The
# dashboard only ever calls it from inside the post-login app shell.
resp = await client.get("/api/v1/system/deployment-mode")
assert resp.status_code == 401
# Body must not leak the recon fields on the unauthenticated path.
assert "role" not in resp.text
assert "swarm_host_count" not in resp.text