diff --git a/gitea_saas/__main__.py b/gitea_saas/__main__.py new file mode 100644 index 0000000..351f6de --- /dev/null +++ b/gitea_saas/__main__.py @@ -0,0 +1,6 @@ +"""``python -m gitea_saas`` dev server (issue #38).""" + +from .app import main + +if __name__ == "__main__": + main() diff --git a/gitea_saas/app.py b/gitea_saas/app.py new file mode 100644 index 0000000..3af657a --- /dev/null +++ b/gitea_saas/app.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index ecd4de4..4e754c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..6d152ac --- /dev/null +++ b/tests/test_app.py @@ -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/", + "/api/clients//credits", + "/api/clients//debits", + "/api/clients//holds", + "/api/holds//release", + "/api/clients//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//issues", + "/api/factory//unlock", + "/api/factory//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 diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..cb78850 --- /dev/null +++ b/wsgi.py @@ -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()