Add a test suite, a linter and a CI gate in front of the build (0.45.0)
CI / check (push) Successful in 7m40s
CI / build-and-push (push) Successful in 1m55s

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:
menzelj
2026-08-31 13:16:41 +02:00
co-authored by Claude Opus 5
parent 54c835b032
commit 60a7ccff93
24 changed files with 1262 additions and 24 deletions
+142
View File
@@ -0,0 +1,142 @@
"""Shared fixtures.
Two things have to happen before anything from the backend is imported, which
is why they sit at module level rather than in a fixture:
* ``DATA_DIR`` / ``STACKS_DIR`` must point at a throwaway directory — ``config``
and ``database`` read them at import time (the SQLite path is computed then),
so a fixture would be too late and the suite would scribble on a real install.
* ``SECRET_KEY`` must be set, or ``config._ensure_secret`` would generate one
and persist it into the temp data dir. Harmless, but a fixed key keeps token
fixtures reproducible.
The app is driven through ``TestClient`` **without** entering it as a context
manager, which deliberately skips the lifespan: no background update/schedule
loops, and no Docker connection. Tests that need database tables depend on the
``db`` fixture, which runs ``init_db()`` once per session.
"""
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
import pytest
_TMP = tempfile.mkdtemp(prefix="stackpilot-tests-")
os.environ["DATA_DIR"] = os.path.join(_TMP, "data")
os.environ["STACKS_DIR"] = os.path.join(_TMP, "stacks")
os.environ["SECRET_KEY"] = "test-secret-key-not-used-anywhere-real"
os.environ["ALLOWED_BROWSE_ROOTS"] = "/mnt,/media,/srv,/opt,/home"
os.environ["HOST_ROOT_PREFIX"] = ""
# The backend runs from its own root at runtime (`uvicorn main:app` with
# /app as the workdir), so make the same layout importable here.
BACKEND_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(BACKEND_ROOT))
os.makedirs(os.environ["DATA_DIR"], exist_ok=True)
os.makedirs(os.environ["STACKS_DIR"], exist_ok=True)
@pytest.fixture(scope="session")
def db():
"""Create the schema in the throwaway SQLite file. Idempotent."""
from database import engine, init_db
init_db()
return engine
@pytest.fixture(scope="session")
def app(db):
from main import app as fastapi_app
return fastapi_app
@pytest.fixture(scope="session")
def users(app):
"""One admin and one plain user, created directly in the DB.
Returns ``(admin, user)`` as detached copies — the ORM objects themselves
would be bound to a closed session.
"""
from sqlmodel import Session, select
import auth as auth_mod
from database import engine
from models.user import User
with Session(engine) as session:
for username, role in (("test-admin", "admin"), ("test-user", "user")):
if not session.exec(select(User).where(User.username == username)).first():
session.add(
User(
username=username,
hashed_password=auth_mod.hash_password("pw-" + username),
role=role,
)
)
session.commit()
admin = session.exec(select(User).where(User.username == "test-admin")).one()
user = session.exec(select(User).where(User.username == "test-user")).one()
session.expunge_all()
return admin, user
@pytest.fixture(scope="session")
def admin_token(users):
import auth as auth_mod
return auth_mod.create_access_token(users[0])
@pytest.fixture(scope="session")
def user_token(users):
import auth as auth_mod
return auth_mod.create_access_token(users[1])
@pytest.fixture(scope="session")
def client(app):
from starlette.testclient import TestClient
# No `with`: skips lifespan, so no background loops and no Docker.
return TestClient(app, raise_server_exceptions=False)
@pytest.fixture
def as_admin(client, admin_token):
return _AuthedClient(client, admin_token)
@pytest.fixture
def as_user(client, user_token):
return _AuthedClient(client, user_token)
class _AuthedClient:
"""Thin wrapper that attaches a bearer token to every request."""
def __init__(self, client, token: str):
self._client = client
self._headers = {"Authorization": f"Bearer {token}"}
def request(self, method: str, url: str, **kwargs):
headers = {**self._headers, **kwargs.pop("headers", {})}
return self._client.request(method, url, headers=headers, **kwargs)
def get(self, url, **kw):
return self.request("GET", url, **kw)
def post(self, url, **kw):
return self.request("POST", url, **kw)
def put(self, url, **kw):
return self.request("PUT", url, **kw)
def delete(self, url, **kw):
return self.request("DELETE", url, **kw)