Files
DECNET/tests/api/fleet/test_mutate_decky.py
anti f2b3393669 chore: relicense to AGPL-3.0-or-later and add SPDX headers
Replaces LICENSE (GPLv3 -> AGPLv3) and prepends
`SPDX-License-Identifier: AGPL-3.0-or-later` to every source file
across decnet/, decnet_web/, tests/, scripts/, and tools/.

Rationale: closes the GPLv3 ASP loophole so any party operating a
modified DECNET as a network service must offer their modified
source. Personal copyright (Samuel Paschuan) + inbound=outbound
contributions make a future unilateral relicense infeasible.

- LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt)
- COPYRIGHT: project copyright notice
- tools/add_spdx_headers.py: idempotent header injector
  (shebang- and PEP 263-aware)

Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh).
No behavior change; comments only.
2026-05-22 21:04:16 -04:00

165 lines
6.2 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Tests for the mutate decky API endpoint — now 202 fire-and-forget.
The handler must:
1. Reject anonymous callers (401).
2. 404 when no active deployment exists.
3. 404 when the named decky isn't in the current state.
4. 422 when decky_name pattern fails validation.
5. On the happy path, create a DeckyLifecycle row, spawn a background
task, return 202 with the row's id.
"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
import httpx
import pytest
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_no_deployment_returns_404(
self, client: httpx.AsyncClient, auth_token: str,
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.delenv("DECNET_CONTRACT_TEST", raising=False)
with patch(
"decnet.web.router.fleet.api_mutate_decky.repo.get_state",
new_callable=AsyncMock, return_value=None,
):
resp = await client.post(
"/api/v1/deckies/decky-01/mutate",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 404
assert "No active deployment" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_unknown_decky_returns_404(
self, client: httpx.AsyncClient, auth_token: str,
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.delenv("DECNET_CONTRACT_TEST", raising=False)
from decnet.config import DecnetConfig, DeckyConfig
cfg = DecnetConfig(
mode="unihost", interface="eth0",
subnet="10.0.0.0/24", gateway="10.0.0.1",
deckies=[DeckyConfig(
name="decky-existing", ip="10.0.0.10",
services=["ssh"], distro="debian",
base_image="debian:bookworm-slim", hostname="d01",
)],
)
with patch(
"decnet.web.router.fleet.api_mutate_decky.repo.get_state",
new_callable=AsyncMock,
return_value={"config": cfg.model_dump(), "compose_path": "c.yml"},
):
resp = await client.post(
"/api/v1/deckies/decky-missing/mutate",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 404
assert "not found" in resp.json()["detail"]
@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
@pytest.mark.asyncio
async def test_successful_mutate_returns_202_with_lifecycle_id(
self, client: httpx.AsyncClient, auth_token: str,
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.delenv("DECNET_CONTRACT_TEST", raising=False)
from decnet.config import DecnetConfig, DeckyConfig
cfg = DecnetConfig(
mode="unihost", interface="eth0",
subnet="10.0.0.0/24", gateway="10.0.0.1",
deckies=[DeckyConfig(
name="decky-01", ip="10.0.0.10",
services=["ssh"], distro="debian",
base_image="debian:bookworm-slim", hostname="d01",
)],
)
spawned: list[str] = []
real_create_task = asyncio.create_task
def _capture(coro, **kw):
spawned.append(kw.get("name", ""))
coro.close()
async def _noop(): return None
return real_create_task(_noop())
with patch(
"decnet.web.router.fleet.api_mutate_decky.repo.get_state",
new_callable=AsyncMock,
return_value={"config": cfg.model_dump(), "compose_path": "c.yml"},
), patch(
"decnet.web.router.fleet.api_mutate_decky.repo.set_state",
new_callable=AsyncMock, return_value=None,
), patch(
"decnet.web.router.fleet.api_mutate_decky.repo.create_lifecycle",
new_callable=AsyncMock, return_value="lid-abc",
), patch(
"decnet.web.router.fleet.api_mutate_decky.pick_new_services",
return_value=["http", "ftp"],
), patch(
"decnet.web.router.fleet.api_mutate_decky.asyncio.create_task",
side_effect=_capture,
):
resp = await client.post(
"/api/v1/deckies/decky-01/mutate",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 202, resp.text
body = resp.json()
assert body["lifecycle_ids"] == ["lid-abc"]
assert spawned and spawned[0].startswith("mutate-")
@pytest.mark.asyncio
async def test_no_services_available_returns_404(
self, client: httpx.AsyncClient, auth_token: str,
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.delenv("DECNET_CONTRACT_TEST", raising=False)
from decnet.config import DecnetConfig, DeckyConfig
cfg = DecnetConfig(
mode="unihost", interface="eth0",
subnet="10.0.0.0/24", gateway="10.0.0.1",
deckies=[DeckyConfig(
name="decky-01", ip="10.0.0.10",
services=["ssh"], distro="debian",
base_image="debian:bookworm-slim", hostname="d01",
)],
)
with patch(
"decnet.web.router.fleet.api_mutate_decky.repo.get_state",
new_callable=AsyncMock,
return_value={"config": cfg.model_dump(), "compose_path": "c.yml"},
), patch(
"decnet.web.router.fleet.api_mutate_decky.pick_new_services",
return_value=None,
):
resp = await client.post(
"/api/v1/deckies/decky-01/mutate",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 404
assert "No services available" in resp.json()["detail"]