Add a test suite, a linter and a CI gate in front of the build (0.45.0)
The repo had no tests, no lint config, and a CI that went straight from push
to docker push. That is the reason F1 could ship: authorization lives in the
routers, each of 171 routes independently picks require_admin or
get_current_user, and nothing checked the choice was right.
670 tests, no Docker daemon needed. The app is driven through TestClient
without entering it as a context manager, which skips the lifespan — no
background loops, no socket — and conftest points DATA_DIR/STACKS_DIR at a
temp directory before anything is imported.
test_route_authorization.py is the load-bearing one. Rather than 171 implied
decisions it states the policy once — every route requires admin unless it is
listed in USER_READABLE or PUBLIC — and fails on any route that disagrees. A
new route defaults to admin, which is the safe direction; what it catches is a
route written with get_current_user that nobody weighed against "can this
return a credential". Writing the allowlist meant auditing all 53 user-readable
routes, which turned up one more leak: GET /api/templates/{id} returns a
template's env, and "save stack as template" snapshots the stack's real .env
into it. Now admin-only; the listing stays open.
test_agent_authorization.py pins the same invariant on the agent, where the
whole access model is one shared token declared per route and a single
forgotten Depends(verify_token) would hand over the host.
Both were checked by reintroducing the bug: re-opening /api/files/read fails
three tests with actionable messages, dropping a token guard fails two.
test_bundled_templates.py covers the 83 templates — parse, image per service,
.env.example in sync with what compose reads, every bind-mounted file actually
shipped, and no working default password. It found one on its first run:
authentik shipped PG_PASS=change-me and AUTHENTIK_SECRET_KEY=change-me against
a compose that marks both required, so the stack would have come up with a
known password instead of refusing to start. Fixed.
The rest ports the ad-hoc harnesses from 0.44.0 into permanent tests (crypto
round-trip incl. plaintext passthrough and key-loss handling, the browse
sandbox) and covers compose_service's slug/status/file handling and
secret_service's name validation.
ruff is configured as a floor, not a style bar: F, E9 and B only. Import
sorting is deliberately out — it is style, and enabling it would rewrite the
imports of nine files that have nothing else wrong. The 12 findings it did have
are fixed here (unused imports, an unused local, four raise-without-from that
were swallowing exception context).
CI now runs check (ruff, pytest, tsc) and only builds if it passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
"""The bundled template library.
|
||||
|
||||
83 templates ship in the image, and a broken one only shows up when somebody
|
||||
pulls it and the deploy fails. These checks are what was run by hand when the
|
||||
library was written, made permanent: every template must be discoverable by
|
||||
the service, parse as YAML, name an image for every service, and keep its
|
||||
``.env.example`` in sync with the variables its compose file actually uses.
|
||||
|
||||
The last one is the rule that keeps the library trustworthy: a ``${VAR}``
|
||||
without a default and without an ``.env.example`` entry deploys as an empty
|
||||
string, which is how you get a container listening on ``:`` or a database with
|
||||
a blank password.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||
|
||||
#: ``${NAME}``, ``${NAME:-default}``, ``${NAME:?required}`` …
|
||||
VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(:?[-?][^}]*)?\}")
|
||||
|
||||
COMPOSE_NAMES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml")
|
||||
|
||||
|
||||
def _template_dirs():
|
||||
return sorted(p for p in TEMPLATES_DIR.iterdir() if p.is_dir())
|
||||
|
||||
|
||||
def _compose_file(folder: Path) -> Path | None:
|
||||
for name in COMPOSE_NAMES:
|
||||
if (folder / name).is_file():
|
||||
return folder / name
|
||||
return None
|
||||
|
||||
|
||||
def _env_example(folder: Path) -> dict[str, str]:
|
||||
path = folder / ".env.example"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
out = {}
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
out[key.strip()] = value.strip()
|
||||
return out
|
||||
|
||||
|
||||
def _compose_vars(text: str) -> dict[str, str]:
|
||||
"""Variable name -> its modifier (``:-``, ``:?`` or empty)."""
|
||||
found: dict[str, str] = {}
|
||||
for match in VAR.finditer(text):
|
||||
name, modifier = match.group(1), match.group(2) or ""
|
||||
# A required marker anywhere wins over a defaulted use elsewhere.
|
||||
if name not in found or modifier.startswith((":?", "?")):
|
||||
found[name] = modifier
|
||||
return found
|
||||
|
||||
|
||||
SLUGS = [p.name for p in _template_dirs()]
|
||||
|
||||
|
||||
def test_the_library_is_not_empty():
|
||||
assert len(SLUGS) > 50, f"only {len(SLUGS)} templates found — is the directory intact?"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", SLUGS)
|
||||
def test_template_is_discoverable_by_the_service(slug):
|
||||
"""What the service lists is what ships — a folder without a compose file
|
||||
is silently skipped by ``_is_template`` and would never appear in the UI."""
|
||||
from services import template_service
|
||||
|
||||
detail = template_service.get_template(slug)
|
||||
assert detail is not None, f"{slug} is on disk but the service does not see it"
|
||||
assert detail["name"]
|
||||
assert detail["compose"].strip()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", SLUGS)
|
||||
def test_metadata_is_complete(slug):
|
||||
meta = json.loads((TEMPLATES_DIR / slug / "template.json").read_text())
|
||||
for key in ("name", "description", "tags", "gpu"):
|
||||
assert key in meta, f"{slug}: template.json is missing '{key}'"
|
||||
assert meta["name"].strip()
|
||||
assert len(meta["description"]) > 20, f"{slug}: description is too thin to be useful"
|
||||
assert isinstance(meta["tags"], list) and meta["tags"], f"{slug}: needs at least one tag"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", SLUGS)
|
||||
def test_compose_parses_and_every_service_names_an_image(slug):
|
||||
folder = TEMPLATES_DIR / slug
|
||||
compose_file = _compose_file(folder)
|
||||
assert compose_file is not None, f"{slug}: no compose file"
|
||||
parsed = yaml.safe_load(compose_file.read_text())
|
||||
assert isinstance(parsed, dict) and parsed.get("services"), f"{slug}: no services block"
|
||||
for name, service in parsed["services"].items():
|
||||
assert "image" in service, f"{slug}: service '{name}' has no image"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", SLUGS)
|
||||
def test_every_variable_has_a_default_or_an_env_entry(slug):
|
||||
"""No ``${VAR}`` may silently interpolate to an empty string.
|
||||
|
||||
Either compose carries a default (``${PORT:-8080}``) or ``.env.example``
|
||||
lists the variable so the user is prompted for it.
|
||||
"""
|
||||
folder = TEMPLATES_DIR / slug
|
||||
env = _env_example(folder)
|
||||
used = _compose_vars(_compose_file(folder).read_text())
|
||||
missing = [
|
||||
name
|
||||
for name, modifier in used.items()
|
||||
if name not in env and not modifier.startswith((":-", "-"))
|
||||
]
|
||||
assert not missing, f"{slug}: {missing} have no default and no .env.example entry"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", SLUGS)
|
||||
def test_env_example_has_no_dead_entries(slug):
|
||||
"""A variable in .env.example that compose never reads is a trap — it looks
|
||||
like a knob and does nothing."""
|
||||
folder = TEMPLATES_DIR / slug
|
||||
used = _compose_vars(_compose_file(folder).read_text())
|
||||
dead = [name for name in _env_example(folder) if name not in used]
|
||||
assert not dead, f"{slug}: .env.example defines {dead}, which compose never uses"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", SLUGS)
|
||||
def test_required_secrets_ship_empty(slug):
|
||||
"""A template must never come with a working default password.
|
||||
|
||||
Anything marked required (``${VAR:?…}``) has to be blank in .env.example so
|
||||
the deploy fails loudly instead of starting with a known credential.
|
||||
"""
|
||||
folder = TEMPLATES_DIR / slug
|
||||
env = _env_example(folder)
|
||||
required = [n for n, m in _compose_vars(_compose_file(folder).read_text()).items()
|
||||
if m.startswith((":?", "?"))]
|
||||
prefilled = [n for n in required if env.get(n)]
|
||||
assert not prefilled, f"{slug}: required secrets {prefilled} ship with a value"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", SLUGS)
|
||||
def test_extra_files_are_shipped_not_just_referenced(slug):
|
||||
"""A ``./file`` bind mount must point at a file the template actually ships,
|
||||
or the deploy creates a *directory* there and the app misreads its config."""
|
||||
folder = TEMPLATES_DIR / slug
|
||||
compose = _compose_file(folder).read_text()
|
||||
for match in re.finditer(r"^\s*-\s*\./([^:\s]+):", compose, re.M):
|
||||
referenced = folder / match.group(1)
|
||||
assert referenced.is_file(), (
|
||||
f"{slug}: compose mounts ./{match.group(1)} but the template does not ship it"
|
||||
)
|
||||
|
||||
|
||||
def test_pulling_a_template_promotes_env_example_to_env(tmp_path):
|
||||
"""The pull path itself: the folder is copied, template.json is left behind
|
||||
and .env.example becomes a real .env."""
|
||||
from services import template_service
|
||||
|
||||
target = tmp_path / "pulled"
|
||||
template_service.copy_into_stack("uptime-kuma", "pulled", override=str(tmp_path))
|
||||
assert (target / "compose.yaml").is_file()
|
||||
assert (target / ".env").is_file()
|
||||
assert not (target / ".env.example").exists()
|
||||
assert not (target / "template.json").exists()
|
||||
Reference in New Issue
Block a user