gitea-saas-core#38: assemble one create_app() + a real entrypoint #43

Merged
pbwraith merged 1 commits from issue-38 into main 2026-09-03 11:04:30 +02:00
5 changed files with 332 additions and 0 deletions

6
gitea_saas/__main__.py Normal file
View File

@ -0,0 +1,6 @@
"""``python -m gitea_saas`` dev server (issue #38)."""
from .app import main
if __name__ == "__main__":
main()

147
gitea_saas/app.py Normal file
View File

@ -0,0 +1,147 @@
"""Assembly: one ``create_app()`` + the entrypoints for the whole service (#38).
The 15 modules on ``main`` compose into a single deployable Flask app in
exactly one place:
billing API ``gitea_saas.server.create_app`` (issues #1/#2/#6)
factory ``gitea_saas.intake_factory.register_factory_routes`` (#4)
dashboard ``gitea_saas.dashboard.mount_dashboard`` (issue #9)
Assembly only: this module makes no behaviour changes to billing, factory,
dashboard or review. If composing them ever surfaces a genuine
incompatibility it is reported on the issue, not papered over here.
Fail-closed assembly: when ``SAAS_API_KEY`` is unset the app still starts
(``python -m gitea_saas``, ``gitea-saas`` console script, gunicorn via the
repo-root ``wsgi.py``) but every authed route returns 503
``SERVICE_UNAVAILABLE``. No second auth path is invented here: key
validation stays in ``gitea_saas.auth`` (issue #24) and each front-end's
own ``auth.key_is_valid`` guard (canonical 401) applies unchanged as soon
as a key IS configured. The 503 gate exists only at this assembly layer,
and only for the unconfigured state.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from types import SimpleNamespace
from typing import Optional
from flask import Flask, jsonify, request
from .auth import PUBLIC_PATHS
from .billing_core import BillingCore
from .config import ServerConfig, load_config
from .dashboard import mount_dashboard
from .gitea_client import GiteaClient
from .intake_factory import register_factory_routes
from .server import create_app as create_billing_app
# Canonical body for the unconfigured-deployment gate (status 503). Same
# ``{"error": {"code", "message"}}`` shape as ``auth.UNAUTHORIZED_BODY`` so
# clients parse every front-end's auth failure identically.
UNAVAILABLE_BODY = {
"error": {
"code": "SERVICE_UNAVAILABLE",
"message": "SAAS_API_KEY is unset: the service is not configured "
"for authenticated use (fail-closed)",
},
}
def _resolve_db_path(cfg: ServerConfig) -> Path:
"""The runtime SQLite path shared by billing, factory and dashboard.
``config.py`` defaults an unset ``SAAS_DB_PATH`` to in-memory, but the
factory and the dashboard open their OWN connections to the same file,
so an ephemeral FILE (in a fresh temp dir) keeps all three components
on one coherent dataset for the no-env dev run. No new env vars: this
is the assembler's default for the existing unset state.
"""
if cfg.db_path is not None:
return Path(cfg.db_path)
scratch = Path(tempfile.mkdtemp(prefix="gitea-saas-"))
return scratch / "saas.db"
def _unconfigured_guard(configured: bool):
"""before_request that fail-closes an UNCONFIGURED deployment.
Registered ONLY when no API key is configured, and inserted ahead of
``server.create_app``'s own before_request so the unconfigured state
is reported as 503 (this issue's acceptance 2: not 500, not 200)
instead of the front-ends' 401s. It performs no key comparison of its
own -- validation stays in ``gitea_saas.auth.key_is_valid`` (issue
#24) -- and it fires only for non-public paths, so the liveness routes
in ``auth.PUBLIC_PATHS`` (/health, /healthz, /api/factory/health)
keep serving. With a configured key this guard is never installed.
"""
if configured:
return None
def _guard():
if request.path.rstrip("/") in PUBLIC_PATHS:
return None
resp = jsonify(UNAVAILABLE_BODY)
resp.status_code = 503
return resp
return _guard
def create_app(config: Optional[ServerConfig] = None) -> Flask:
"""Build the ONE Flask app: billing + factory + dashboard mounted.
``config`` is a :class:`~gitea_saas.config.ServerConfig` (or any object
exposing ``api_key`` / ``port`` / ``db_path``); when None the
environment is loaded through the existing ``config.load_config``
loader (no new env vars).
The DB is opened via ``gitea_saas.db`` (through ``BillingCore``, which
owns the connection + idempotent schema bootstrap) and shared by all
three front-ends. With no ``SAAS_API_KEY`` the app still starts;
authed routes return 503 until a key is configured (module docstring).
"""
cfg = config if config is not None else load_config()
api_key = getattr(cfg, "api_key", None) or ""
db_path = _resolve_db_path(cfg)
core = BillingCore(db_path)
# The factory's IntakeFactory is built per request and reads
# ``config.db_path`` (env fallback is the pre-assembly behaviour);
# hand it the RESOLVED path so the per-request construction cannot
# raise "IntakeFactory requires config.db_path" for the no-env case.
runtime = SimpleNamespace(db_path=db_path, api_key=api_key)
app = create_billing_app(core, api_key)
register_factory_routes(app, core, GiteaClient(), runtime)
mount_dashboard(app, core=core, db_path=db_path, api_key=api_key)
guard = _unconfigured_guard(bool(api_key))
if guard is not None:
# Global before_request funcs run in list order; position 0 puts
# the unconfigured gate ahead of server.create_app's own guard.
# A returning response short-circuits the rest of the request, so
# authed routes report the deployment state before any front-end
# 401 logic runs. When a key IS configured this list is never
# touched and the front-ends' auth.key_is_valid guards decide.
app.before_request_funcs.setdefault(None, []).insert(0, guard)
return app
def main() -> None:
"""Dev entrypoint (``python -m gitea_saas`` / ``gitea-saas`` script).
Starts even with no ``SAAS_API_KEY`` (fail-closed 503, per the issue);
binds loopback like the pre-assembly server entrypoint. Production
runs under gunicorn against the repo-root ``wsgi.py`` instead.
"""
cfg = load_config()
app = create_app(cfg)
app.run(host="127.0.0.1", port=cfg.port)
if __name__ == "__main__":
main()

View File

@ -3,6 +3,12 @@ name = "gitea-saas-core"
version = "0.1.0"
description = "Shared billing core (clients/ledger/holds, idempotent, fail-closed) for gitea-saas"
requires-python = ">=3.10"
dependencies = [
"flask>=3.0",
]
[project.scripts]
gitea-saas = "gitea_saas.app:main"
[tool.pytest.ini_options]
testpaths = ["tests"]

163
tests/test_app.py Normal file
View File

@ -0,0 +1,163 @@
"""Tests for the assembled app (issue #38): one ``create_app()`` + entrypoints.
Hermetic: temp SQLite DBs, Flask test client, no network / Stripe / Gitea.
Acceptance contract exercised here:
1. ``create_app()``'s URL map contains the billing routes, the
``/api/factory/*`` routes, ``/dashboard`` and ``/healthz`` (asserted
on the URL map, not by requesting each one).
2. Starting the app with NO ``SAAS_API_KEY`` still starts, and every
authed route returns 503 -- not 500 and not 200; the liveness routes
stay public.
3. ``python -m gitea_saas`` serves ``/health`` locally (verified live as
part of the PR's test evidence; not a subprocess test here).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from gitea_saas import app as app_module
from gitea_saas.app import create_app
from gitea_saas.auth import API_KEY_HEADER, UNAUTHORIZED_BODY
from gitea_saas.config import ServerConfig
API_KEY = "test-api-key-secret"
AUTH = {API_KEY_HEADER: API_KEY}
def _cfg(tmp_path: Path, api_key: str | None = API_KEY) -> ServerConfig:
return ServerConfig(api_key=api_key, port=8080, db_path=tmp_path / "saas.db")
@pytest.fixture()
def app(tmp_path: Path):
application = create_app(_cfg(tmp_path))
application.config.update(TESTING=True)
return application
@pytest.fixture()
def client(app):
return app.test_client()
def _rules(app) -> set[str]:
return {str(r.rule) for r in app.url_map.iter_rules()}
# ---------------------------------------------------------------------------
# Acceptance 1: the URL map composes every front-end
# ---------------------------------------------------------------------------
def test_url_map_contains_billing_routes(app):
rules = _rules(app)
for expected in (
"/api/clients",
"/api/clients/<int:client_id>",
"/api/clients/<int:client_id>/credits",
"/api/clients/<int:client_id>/debits",
"/api/clients/<int:client_id>/holds",
"/api/holds/<int:hold_id>/release",
"/api/clients/<int:client_id>/ledger",
):
assert expected in rules, f"missing billing route {expected}"
def test_url_map_contains_factory_routes(app):
rules = _rules(app)
for expected in (
"/api/factory/health",
"/api/factory/provision",
"/api/factory/<int:client_id>/issues",
"/api/factory/<int:client_id>/unlock",
"/api/factory/<int:client_id>/lock",
):
assert expected in rules, f"missing factory route {expected}"
def test_url_map_contains_dashboard_and_liveness_routes(app):
rules = _rules(app)
for expected in ("/dashboard", "/api/dashboard/summary",
"/healthz", "/health"):
assert expected in rules, f"missing route {expected}"
# ---------------------------------------------------------------------------
# Acceptance 2: no SAAS_API_KEY -> app starts, authed routes fail closed 503
# ---------------------------------------------------------------------------
def test_unset_api_key_fails_closed_503(tmp_path, monkeypatch):
monkeypatch.delenv("SAAS_API_KEY", raising=False)
application = create_app(_cfg(tmp_path, api_key=None))
application.config.update(TESTING=True)
c = application.test_client()
# authed routes: 503 -- not 500, not 200
assert c.get("/api/clients/1").status_code == 503
assert c.post("/api/clients", json={"name": "X"}).status_code == 503
assert c.get("/dashboard").status_code == 503
assert c.get("/api/dashboard/summary").status_code == 503
assert c.post("/api/factory/provision",
json={"client_id": 1}).status_code == 503
# liveness routes in auth.PUBLIC_PATHS stay reachable without a key
for path in ("/health", "/healthz", "/api/factory/health"):
assert c.get(path).status_code == 200, path
def test_default_create_app_with_no_env_starts(monkeypatch):
monkeypatch.delenv("SAAS_API_KEY", raising=False)
monkeypatch.delenv("SAAS_DB_PATH", raising=False)
application = create_app() # nothing set: config loader defaults
application.config.update(TESTING=True)
c = application.test_client()
assert c.get("/health").status_code == 200
assert c.get("/healthz").status_code == 200
assert c.get("/api/clients/1").status_code == 503
rules = _rules(application)
assert "/dashboard" in rules
assert "/api/factory/provision" in rules
# ---------------------------------------------------------------------------
# Configured key: the front-ends' own auth.key_is_valid guards are untouched
# ---------------------------------------------------------------------------
def test_configured_key_serves_billing(client):
created = client.post("/api/clients", json={"name": "Assembled"},
headers=AUTH)
assert created.status_code == 201
cid = created.get_json()["id"]
resp = client.get(f"/api/clients/{cid}", headers=AUTH)
assert resp.status_code == 200
assert resp.get_json()["name"] == "Assembled"
def test_wrong_key_is_canonical_401(client):
resp = client.post("/api/clients", json={"name": "X"},
headers={API_KEY_HEADER: "wrong"})
assert resp.status_code == 401
assert resp.get_json() == UNAUTHORIZED_BODY
# ---------------------------------------------------------------------------
# Entrypoints exist and are wired to the same main()
# ---------------------------------------------------------------------------
def test_console_entrypoint_target_exists():
# [project.scripts] gitea-saas = "gitea_saas.app:main"
assert callable(app_module.main)
def test_python_m_gitea_saas_uses_app_main():
from gitea_saas import __main__ as main_module
assert main_module.main is app_module.main

10
wsgi.py Normal file
View File

@ -0,0 +1,10 @@
"""Gunicorn entrypoint (issue #38).
``gunicorn wsgi:application`` — the assembled app built by
``gitea_saas.app.create_app`` from the environment (SAAS_API_KEY,
SAAS_DB_PATH, SAAS_PORT).
"""
from gitea_saas.app import create_app
application = create_app()