Files
DECNET/tests/web/test_api_startup_guards.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

133 lines
4.6 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""Master API startup guards: mode gating + eager JWT load.
The lifespan is what enforces these. We invoke it directly with a fresh
FastAPI app instance rather than spinning up a TestClient — TestClient
fixtures elsewhere set DECNET_JWT_SECRET globally and would mask the
"missing secret fails at boot" assertion.
"""
from __future__ import annotations
import asyncio
import importlib
import sys
import pytest
from fastapi import FastAPI
def _reload_api(monkeypatch: pytest.MonkeyPatch):
for mod in list(sys.modules):
if mod == "decnet.env" or mod == "decnet.web.api" or mod.startswith("decnet.env."):
sys.modules.pop(mod)
return importlib.import_module("decnet.web.api")
def _strip_pytest_vars(monkeypatch: pytest.MonkeyPatch) -> None:
import os
for k in list(os.environ):
if k.startswith("PYTEST"):
monkeypatch.delenv(k, raising=False)
async def _run_lifespan_startup(api_mod) -> None:
"""Run the lifespan up to (but not past) yield, then unwind cleanly.
DECNET_CONTRACT_TEST suppresses all background workers (ingestion,
collector, TTP, tarpit) so no tasks escape test teardown.
"""
import os
os.environ["DECNET_CONTRACT_TEST"] = "true"
try:
app = FastAPI()
cm = api_mod.lifespan(app)
await cm.__aenter__()
try:
return
finally:
try:
await cm.__aexit__(None, None, None)
except Exception:
pass
finally:
os.environ.pop("DECNET_CONTRACT_TEST", None)
def test_master_api_refuses_to_start_in_agent_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("DECNET_MODE", "agent")
monkeypatch.setenv("DECNET_DISALLOW_MASTER", "true")
monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32)
api = _reload_api(monkeypatch)
with pytest.raises(RuntimeError, match="master-only"):
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
_run_lifespan_startup(api)
)
def test_master_api_starts_when_dual_role_enabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""DECNET_DISALLOW_MASTER=false is the documented escape hatch for
dev hosts that play both sides — must not trip the gate."""
monkeypatch.setenv("DECNET_MODE", "agent")
monkeypatch.setenv("DECNET_DISALLOW_MASTER", "false")
monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32)
api = _reload_api(monkeypatch)
# Reaching the DB init phase means the gate passed; we don't need to
# actually finish startup. Cancel via a synthetic exception that the
# lifespan doesn't catch.
# Reaching repo.initialize means the gate passed. We don't actually
# need DB to come up — short-circuit and assert no master-only raise.
seen: list[str] = []
async def _spy(*_a, **_kw):
seen.append("init_called")
monkeypatch.setattr(api.repo, "initialize", _spy)
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
_run_lifespan_startup(api)
)
assert seen == ["init_called"], "DB init should have been reached, gate must be inert"
def test_master_api_eager_loads_jwt_secret_at_startup(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The lifespan must touch DECNET_JWT_SECRET so a missing/insecure
value fails at boot rather than on the first auth-gated request.
We can't realistically exercise the raise-on-missing path in this
repo: dev hosts have a populated .env.local that dotenv auto-loads,
and conftest seeds a JWT secret globally. The actual raise behaviour
is covered by tests/web/test_env_lazy_jwt.py — here we just assert
the lifespan calls into the env module's lazy resolver.
"""
monkeypatch.setenv("DECNET_MODE", "master")
monkeypatch.setenv("DECNET_JWT_SECRET", "y" * 32)
monkeypatch.setenv("DECNET_API_HOST", "127.0.0.1")
monkeypatch.delenv("DECNET_CORS_ORIGINS", raising=False)
api = _reload_api(monkeypatch)
import decnet.env as env_mod
seen: list[str] = []
real_getattr = env_mod.__getattr__
def _spy(name: str) -> str:
seen.append(name)
return real_getattr(name)
monkeypatch.setattr(env_mod, "__getattr__", _spy, raising=False)
async def _noop_init(*_a, **_kw) -> None:
return None
monkeypatch.setattr(api.repo, "initialize", _noop_init)
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
_run_lifespan_startup(api)
)
assert "DECNET_JWT_SECRET" in seen, (
"lifespan must access env.DECNET_JWT_SECRET at startup"
)