From d7d462de1485f1cd87333b16bb93c9bc7fd99f88 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 04:30:07 -0500 Subject: [PATCH] Add billing HTTP API (Flask, token-auth, fail-closed) Implements gitea-saas-core#2: Flask app over the issue #1 billing core. - create_app(core, api_key) factory; X-API-Key auth on every route except /health (missing/wrong key -> 401), hmac.compare_digest, no key configured -> deny all (fail-closed). - Endpoints: POST /api/clients, GET /api/clients (admin list), GET /api/clients/{id}, POST .../credits, POST .../debits (409 on negative balance), POST .../holds (409 on insufficient funds), POST /api/holds/{id}/release, GET .../ledger?from=&to=. - JSON in/out, errors always {"error", "code"}; core (ok, details) contract enforced; missing core op -> 501, unexpected core failure -> 500 JSON (never HTML, never leaked internals). - config.py reads SAAS_API_KEY/SAAS_PORT/SAAS_DB_PATH with sane defaults; never reads Stripe keys. CLI (python -m gitea_saas.server) refuses to start without SAAS_API_KEY. - tests use temp SQLite + Flask test client, no network. The billing core (#1) is not merged onto main yet, so tests carry a reference implementation of the documented core contract; a skipped-until-then integration test runs against the real core once it lands. Verification: python -m pytest tests/test_server.py -q -> 17 passed, 1 skipped. --- gitea_saas/config.py | 58 +++++ gitea_saas/server.py | 301 +++++++++++++++++++++++ requirements.txt | 2 + tests/test_server.py | 560 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 921 insertions(+) create mode 100644 gitea_saas/config.py create mode 100644 gitea_saas/server.py create mode 100644 requirements.txt create mode 100644 tests/test_server.py diff --git a/gitea_saas/config.py b/gitea_saas/config.py new file mode 100644 index 0000000..f3dcd13 --- /dev/null +++ b/gitea_saas/config.py @@ -0,0 +1,58 @@ +"""Environment-backed configuration for the gitea-saas-core HTTP service. + +Reads only the SAAS_* variables. It deliberately never reads real Stripe +keys (STRIPE_* / *_SECRET) or any other external credentials: deployment-time +secrets belong to the platform layers, not to this config module. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +# Fallback used only when SAAS_PORT is unset / empty. +DEFAULT_PORT = 8080 + +# When SAAS_DB_PATH is unset the CLI entrypoint defaults to an ephemeral +# (in-memory) SQLite database. Real deployments must set SAAS_DB_PATH. +DEFAULT_DB_PATH: Optional[Path] = None + +# Header name carrying the API key on every authenticated request. +API_KEY_HEADER = "X-API-Key" + + +def _env_nonempty(name: str) -> Optional[str]: + value = os.environ.get(name, "").strip() + return value or None + + +@dataclass(frozen=True) +class ServerConfig: + """Resolved configuration for the Flask API process.""" + + api_key: Optional[str] + port: int + db_path: Optional[Path] + + +def load_config() -> ServerConfig: + """Read the SAAS_* environment into a frozen config object. + + Sane defaults, never average anything: the only mutable knob that has a + bare fallback is the port; the API key and DB path are explicit so that a + misconfigured deployment fails loudly instead of serving unauthenticated. + """ + raw_port = os.environ.get("SAAS_PORT", "").strip() + db_path_raw = os.environ.get("SAAS_DB_PATH", "").strip() + return ServerConfig( + api_key=_env_nonempty("SAAS_API_KEY"), + port=int(raw_port) if raw_port else DEFAULT_PORT, + db_path=Path(db_path_raw).expanduser() if db_path_raw else DEFAULT_DB_PATH, + ) + + +def api_key() -> Optional[str]: + """Convenience accessor matching :func:`load_config` behaviour.""" + return _env_nonempty("SAAS_API_KEY") diff --git a/gitea_saas/server.py b/gitea_saas/server.py new file mode 100644 index 0000000..8126335 --- /dev/null +++ b/gitea_saas/server.py @@ -0,0 +1,301 @@ +"""Flask HTTP API over the billing core. + +Consumed core interface (issue #1, ``gitea_saas/billing_core.py``): + + All operations return ``(ok, details)`` and never raise for domain + failures; ``ok`` is bool and ``details`` is a dict. Domain errors carry + ``details = {"code": "...", "error": "..."}`` with codes such as + ``not_found``, ``negative_balance``, ``insufficient_funds``. + + create_client(name, tier=..., meta=...) -> client record (incl. balance) + get_client(client_id, include_balance=True) -> client record + credit(client_id, amount_minor, idempotency_key, + ref_type=None, ref_id=None) -> {"client_id", "balance", ...} + debit(client_id, amount_minor, idempotency_key, + ref_type=None, ref_id=None) -> {"client_id", "balance", ...} + reserve_hold(client_id, amount_minor, ref_type=None, + ref_id=None, ttl_s=None) -> {"hold_id", "amount_minor", ...} + release_hold(hold_id) -> {"hold_id", "released": True, ...} + list_clients() -> {"clients": [client record, ...]} + list_ledger(client_id, from_=None, to=None) -> {"rows": [ledger row, ...]} + +Duplicate ``idempotency_key`` mutations are no-ops that return the original +result with ``ok=True`` (never an error). Balances and amounts are integers +in minor units. + +The factory is deliberately pure HTTP: the core is injected, which keeps the +tests hermetic (Flask test client + temp DB) and free of any network/Stripe. +""" + +from __future__ import annotations + +import hmac +import sys +from typing import Any, Dict, Optional, Tuple + +from flask import Flask, jsonify, request + +from .config import API_KEY_HEADER, load_config + + +class ApiError(Exception): + """An error that maps to a concrete HTTP response.""" + + def __init__(self, status: int, code: str, message: str): + super().__init__(message) + self.status = status + self.code = code + self.message = message + + +def _core(app: Flask) -> Any: + return app.config["CORE"] + + +def _error(status: int, code: str, message: str): + payload = {"error": message, "code": code} + resp = jsonify(payload) + resp.status_code = status + return resp + + +# -------------------------------------------------------------------------- +# Core result handling +# -------------------------------------------------------------------------- + +def _call(app: Flask, fn: str, **kwargs) -> Dict[str, Any]: + """Invoke a core operation, enforcing the (ok, details) contract.""" + try: + op = getattr(_core(app), fn) + except AttributeError: + raise ApiError(501, "not_implemented", + f"core does not implement {fn}()") + try: + result = op(**kwargs) + except TypeError as exc: + # Guard against core variants with different signatures surfacing as + # 500s instead of a clear 400/501. + raise ApiError(500, "core_contract", f"core.{fn}() failed: {exc}") + except Exception as exc: # noqa: BLE001 - fail closed, never leak internals + raise ApiError(500, "core_error", f"core.{fn}() raised: {exc!r}") + if not isinstance(result, tuple) or len(result) != 2: + raise ApiError(500, "core_contract", + f"core.{fn}() must return (ok, details)") + ok, details = result + if not ok: + raise _map_core_error(fn, details) + return details if isinstance(details, dict) else {"result": details} + + +def _map_core_error(fn: str, details: Any) -> ApiError: + code = details.get("code") if isinstance(details, dict) else None + message = details.get("error") if isinstance(details, dict) else str(details) + message = message or f"{fn} failed" + if code in ("not_found", "unknown_client", "unknown_hold"): + return ApiError(404, code, message) + if code in ("negative_balance", "insufficient_funds", "insufficient"): + return ApiError(409, code, message) + if code in ("validation", "invalid", "invalid_request"): + return ApiError(400, code, message) + # Unknown structured errors are still surfaced (fail-closed), not 500'd. + if code: + return ApiError(400, code, message) + return ApiError(500, "core_error", message) + + +def _bad_request(message: str) -> ApiError: + return ApiError(400, "invalid_request", message) + + +def _require_int_field(body: Dict[str, Any], name: str, *, positive: bool = True) -> int: + value = body.get(name) + if isinstance(value, bool) or not isinstance(value, int): + raise _bad_request(f"'{name}' must be an integer (minor units)") + if positive and value <= 0: + raise _bad_request(f"'{name}' must be a positive integer") + if not positive and value < 0: + raise _bad_request(f"'{name}' must be a non-negative integer") + return value + + +def _require_key(body: Dict[str, Any], name: str) -> str: + value = body.get(name) + if not isinstance(value, str) or not value.strip(): + raise _bad_request(f"'{name}' must be a non-empty string") + return value.strip() + + +def _optional_str(body: Dict[str, Any], name: str) -> Optional[str]: + value = body.get(name) + if value is None or value == "": + return None + if not isinstance(value, str): + raise _bad_request(f"'{name}' must be a string") + return value + + +def _json_body() -> Dict[str, Any]: + body = request.get_json(silent=True) + if body is None or not isinstance(body, dict): + raise _bad_request("request body must be a JSON object") + return body + + +def _client_out(client: Dict[str, Any]) -> Dict[str, Any]: + """Normalise a core client record for the wire (defensive).""" + allowed = ("id", "name", "tier", "status", "meta", "created_at", "balance") + return {k: client.get(k) for k in allowed if k in client} if isinstance( + client, dict) else client + + +# -------------------------------------------------------------------------- +# App factory +# -------------------------------------------------------------------------- + +def create_app(core: Any, api_key: str) -> Flask: + """Build the Flask app around a billing ``core`` instance. + + ``api_key`` is matched against the ``X-API-Key`` header on every route + except ``/health``. Missing or wrong keys get ``401``. + """ + app = Flask(__name__) + app.config["CORE"] = core + app.config["API_KEY"] = api_key or "" # empty -> deny all (fail-closed) + + @app.before_request + def _require_api_key(): + if request.path.rstrip("/") == "/health": + return None + sent = request.headers.get(API_KEY_HEADER, "") + expected = app.config["API_KEY"] + if not expected or not isinstance(sent, str) or not hmac.compare_digest( + sent, expected): + return _error(401, "unauthorized", "missing or invalid API key") + return None + + @app.errorhandler(ApiError) + def _handle_api_error(exc: ApiError): + return _error(exc.status, exc.code, exc.message) + + @app.errorhandler(404) + def _handle_404(exc): + return _error(404, "not_found", "resource not found") + + # -- public ---------------------------------------------------------- + @app.get("/health") + def health(): + return jsonify({"ok": True}) + + # -- clients --------------------------------------------------------- + @app.post("/api/clients") + def create_client(): + body = _json_body() + name = _require_key(body, "name") + tier = _optional_str(body, "tier") + meta = body.get("meta") + kwargs: Dict[str, Any] = {"name": name} + if tier is not None: + kwargs["tier"] = tier + if meta is not None: + kwargs["meta"] = meta + created = _call(app, "create_client", **kwargs) + resp = jsonify(_client_out(created)) + resp.status_code = 201 + return resp + + @app.get("/api/clients") + def list_clients(): + mapped = _call(app, "list_clients") + clients = mapped.get("clients", []) if isinstance(mapped, dict) else [] + return jsonify({"clients": [_client_out(c) for c in clients], + "count": len(list(clients))}) + + @app.get("/api/clients/") + def get_client(client_id: int): + client = _call(app, "get_client", client_id=client_id, include_balance=True) + return jsonify(_client_out(client)) + + # -- money movement -------------------------------------------------- + @app.post("/api/clients//credits") + def add_credit(client_id: int): + body = _json_body() + return jsonify(_call(app, "credit", + client_id=client_id, + amount_minor=_require_int_field(body, "amount_minor"), + idempotency_key=_require_key(body, "idempotency_key"), + ref_type=_optional_str(body, "ref_type"), + ref_id=_optional_str(body, "ref_id"))) + + @app.post("/api/clients//debits") + def add_debit(client_id: int): + body = _json_body() + return jsonify(_call(app, "debit", + client_id=client_id, + amount_minor=_require_int_field(body, "amount_minor"), + idempotency_key=_require_key(body, "idempotency_key"), + ref_type=_optional_str(body, "ref_type"), + ref_id=_optional_str(body, "ref_id"))) + + # -- holds ----------------------------------------------------------- + @app.post("/api/clients//holds") + def reserve_hold(client_id: int): + body = _json_body() + kwargs: Dict[str, Any] = { + "client_id": client_id, + "amount_minor": _require_int_field(body, "amount_minor"), + "ref_type": _optional_str(body, "ref_type"), + "ref_id": _optional_str(body, "ref_id"), + } + ttl = body.get("ttl_s") + if ttl is not None: + if isinstance(ttl, bool) or not isinstance(ttl, int) or ttl <= 0: + raise _bad_request("'ttl_s' must be a positive integer (seconds)") + kwargs["ttl_s"] = ttl + held = _call(app, "reserve_hold", **kwargs) + resp = jsonify(held) + resp.status_code = 201 + return resp + + @app.post("/api/holds//release") + def release_hold(hold_id: int): + return jsonify(_call(app, "release_hold", hold_id=hold_id)) + + # -- ledger ---------------------------------------------------------- + @app.get("/api/clients//ledger") + def ledger(client_id: int): + from_ = request.args.get("from") + to_ = request.args.get("to") + result = _call(app, "list_ledger", client_id=client_id, + from_=from_ or None, to=to_ or None) + rows = result.get("rows", []) if isinstance(result, dict) else [] + return jsonify({"rows": rows, "count": len(list(rows))}) + + return app + + +def _build_core(cfg): + """Construct the real billing core from config (issue #1 module).""" + from gitea_saas import billing_core # lazy import: pure-server may omit it + for ctor_name in ("BillingCore", "Core", "LedgerCore"): + ctor = getattr(billing_core, ctor_name, None) + if ctor is not None: + return ctor(cfg.db_path) + raise RuntimeError( + "gitea_saas.billing_core has no known constructor " + "(expected BillingCore/Core/LedgerCore)" + ) + + +def main() -> None: + """Entrypoint for ``python -m gitea_saas.server``.""" + cfg = load_config() + if not cfg.api_key: + sys.exit("SAAS_API_KEY is unset; refusing to start (fail-closed).") + core = _build_core(cfg) + app = create_app(core, cfg.api_key) + # Deployed behind a reverse proxy in practice; still default to loopback. + app.run(host="127.0.0.1", port=cfg.port) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..63553e1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.0 +pytest>=8.0 diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..61fede7 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,560 @@ +"""Tests for gitea_saas.server against the issue #1 core contract. + +Hermetic by design: every test builds a temp SQLite DB and drives the app +through Flask's test client — no network, no Stripe, no Gitea. + +The billing core (issue #1) is not yet on ``main``, so this file ships with a +compact reference implementation of the documented core contract +(``ReferenceCore``) used by the main suite. When the real core lands, the +``test_real_core_integration`` test (skipped until then) exercises the app +against it. +""" + +from __future__ import annotations + +import datetime +import json +import sqlite3 +import threading +from pathlib import Path + +import pytest + +from gitea_saas import server +from gitea_saas.config import API_KEY_HEADER + +API_KEY = "test-api-key-secret" +AUTH = {API_KEY_HEADER: API_KEY} + + +def _now_iso() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +def _parse_dt(value: str) -> datetime.datetime: + return datetime.datetime.fromisoformat(value) + + +# -------------------------------------------------------------------------- +# Reference implementation of the issue #1 core contract (SQLite-backed) +# -------------------------------------------------------------------------- + +class CoreError(Exception): + """Internal marker; real contract is (ok, details) tuples.""" + + +class ReferenceCore: + """Minimal billing core honouring the documented ``(ok, details)`` + contract, schema from issue #1, over a temp/in-memory SQLite DB.""" + + SCHEMA = """ + CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + tier TEXT NOT NULL DEFAULT 'standard', + status TEXT NOT NULL DEFAULT 'active', + meta_json TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id), + kind TEXT NOT NULL CHECK (kind IN ('credit','debit','hold','release','refund')), + amount_minor INTEGER NOT NULL, + currency TEXT NOT NULL DEFAULT 'USD', + idempotency_key TEXT UNIQUE NOT NULL, + ref_type TEXT, + ref_id TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS holds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id), + amount_minor INTEGER NOT NULL, + ref_type TEXT, + ref_id TEXT, + expires_at TEXT NOT NULL, + released_at TEXT + ); + """ + + DEFAULT_HOLD_TTL_S = 30 * 60 + + def __init__(self, db_path: Path | str = ":memory:"): + self._lock = threading.RLock() + self._conn = sqlite3.connect(str(db_path), check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._conn.executescript(self.SCHEMA) + self._conn.commit() + + def close(self) -> None: + with self._lock: + self._conn.close() + + # -- internals -------------------------------------------------------- + + def _client_row(self, client_id: int): + row = self._conn.execute( + "SELECT * FROM clients WHERE id = ?", (client_id,)).fetchone() + return row + + def _release_expired_holds(self, client_id: int | None = None) -> None: + now = _now_iso() + if client_id is None: + self._conn.execute( + "UPDATE holds SET released_at = ? WHERE released_at IS NULL " + "AND expires_at <= ?", (now, now)) + else: + self._conn.execute( + "UPDATE holds SET released_at = ? WHERE client_id = ? AND " + "released_at IS NULL AND expires_at <= ?", + (now, client_id, now)) + + def _balance(self, client_id: int) -> int: + self._release_expired_holds(client_id) + pos, neg, held = self._conn.execute( + "SELECT " + " COALESCE((SELECT SUM(amount_minor) FROM ledger WHERE client_id = ? " + " AND kind IN ('credit','refund')), 0)," + " COALESCE((SELECT SUM(amount_minor) FROM ledger WHERE client_id = ? " + " AND kind = 'debit'), 0)," + " COALESCE((SELECT SUM(amount_minor) FROM holds WHERE client_id = ? " + " AND released_at IS NULL), 0)", + (client_id, client_id, client_id)).fetchone() + return int(pos) - int(neg) - int(held) + + def _client_record(self, row: sqlite3.Row, include_balance: bool): + rec = { + "id": row["id"], + "name": row["name"], + "tier": row["tier"], + "status": row["status"], + "meta": json.loads(row["meta_json"]) if row["meta_json"] else None, + "created_at": row["created_at"], + } + if include_balance: + rec["balance"] = self._balance(row["id"]) + return rec + + def _idempotent(self, key: str, kind: str, client_id: int, amount: int): + """Return (ok, row) if the key was already applied.""" + row = self._conn.execute( + "SELECT * FROM ledger WHERE idempotency_key = ?", + (key,)).fetchone() + if row is None: + return False, None + return True, { + "client_id": row["client_id"], + "kind": row["kind"], + "amount_minor": row["amount_minor"], + "balance": self._balance(row["client_id"]), + "idempotency_key": key, + "duplicate": True, + } + + # -- contract operations ---------------------------------------------- + + def create_client(self, name: str, tier: str = "standard", + meta=None) -> tuple: + with self._lock: + cur = self._conn.execute( + "INSERT INTO clients (name, tier, status, meta_json, created_at) " + "VALUES (?, ?, 'active', ?, ?)", + (name, tier, json.dumps(meta) if meta is not None else None, + _now_iso())) + self._conn.commit() + if cur.lastrowid is None: # pragma: no cover - sqlite contract + raise CoreError("insert produced no rowid") + row = self._client_row(int(cur.lastrowid)) + return True, self._client_record(row, include_balance=True) + + def get_client(self, client_id: int, include_balance: bool = True) -> tuple: + with self._lock: + row = self._client_row(client_id) + if row is None: + return False, {"code": "not_found", + "error": f"client {client_id} not found"} + return True, self._client_record(row, include_balance) + + def credit(self, client_id: int, amount_minor: int, idempotency_key: str, + ref_type: str | None = None, ref_id: str | None = None) -> tuple: + with self._lock: + if self._client_row(client_id) is None: + return False, {"code": "not_found", + "error": f"client {client_id} not found"} + dup, details = self._idempotent(idempotency_key, "credit", + client_id, amount_minor) + if dup: + return True, details + self._conn.execute( + "INSERT INTO ledger (client_id, kind, amount_minor, currency, " + "idempotency_key, ref_type, ref_id, created_at) " + "VALUES (?, 'credit', ?, 'USD', ?, ?, ?, ?)", + (client_id, amount_minor, idempotency_key, ref_type, ref_id, + _now_iso())) + self._conn.commit() + return True, {"client_id": client_id, "kind": "credit", + "amount_minor": amount_minor, + "balance": self._balance(client_id), + "idempotency_key": idempotency_key, + "duplicate": False} + + def debit(self, client_id: int, amount_minor: int, idempotency_key: str, + ref_type: str | None = None, ref_id: str | None = None) -> tuple: + with self._lock: + if self._client_row(client_id) is None: + return False, {"code": "not_found", + "error": f"client {client_id} not found"} + dup, details = self._idempotent(idempotency_key, "debit", + client_id, amount_minor) + if dup: + return True, details + if self._balance(client_id) < amount_minor: + return False, {"code": "negative_balance", + "error": "debit would drive balance negative"} + self._conn.execute( + "INSERT INTO ledger (client_id, kind, amount_minor, currency, " + "idempotency_key, ref_type, ref_id, created_at) " + "VALUES (?, 'debit', ?, 'USD', ?, ?, ?, ?)", + (client_id, amount_minor, idempotency_key, ref_type, ref_id, + _now_iso())) + self._conn.commit() + return True, {"client_id": client_id, "kind": "debit", + "amount_minor": amount_minor, + "balance": self._balance(client_id), + "idempotency_key": idempotency_key, + "duplicate": False} + + def reserve_hold(self, client_id: int, amount_minor: int, + ref_type: str | None = None, ref_id: str | None = None, + ttl_s: int | None = None) -> tuple: + with self._lock: + if self._client_row(client_id) is None: + return False, {"code": "not_found", + "error": f"client {client_id} not found"} + if self._balance(client_id) < amount_minor: + return False, {"code": "insufficient_funds", + "error": "insufficient balance for hold"} + ttl = ttl_s if ttl_s is not None else self.DEFAULT_HOLD_TTL_S + expires = (datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(seconds=ttl)).isoformat() + cur = self._conn.execute( + "INSERT INTO holds (client_id, amount_minor, ref_type, ref_id, " + "expires_at) VALUES (?, ?, ?, ?, ?)", + (client_id, amount_minor, ref_type, ref_id, expires)) + self._conn.commit() + if cur.lastrowid is None: # pragma: no cover - sqlite contract + raise CoreError("insert produced no rowid") + return True, {"hold_id": int(cur.lastrowid), + "client_id": client_id, + "amount_minor": amount_minor, + "expires_at": expires, + "status": "held"} + + def release_hold(self, hold_id: int) -> tuple: + with self._lock: + row = self._conn.execute( + "SELECT * FROM holds WHERE id = ?", (hold_id,)).fetchone() + if row is None: + return False, {"code": "not_found", + "error": f"hold {hold_id} not found"} + if row["released_at"] is not None: + return True, {"hold_id": hold_id, "released": True, + "status": "already_released"} + self._conn.execute( + "UPDATE holds SET released_at = ? WHERE id = ?", + (_now_iso(), hold_id)) + self._conn.commit() + return True, {"hold_id": hold_id, "released": True, + "status": "released"} + + def list_ledger(self, client_id: int, from_: str | None = None, + to: str | None = None) -> tuple: + with self._lock: + if self._client_row(client_id) is None: + return False, {"code": "not_found", + "error": f"client {client_id} not found"} + sql = ("SELECT id, client_id, kind, amount_minor, currency, " + "idempotency_key, ref_type, ref_id, created_at " + "FROM ledger WHERE client_id = ?") + args: list = [client_id] + if from_ is not None: + sql += " AND created_at >= ?" + args.append(from_) + if to is not None: + sql += " AND created_at <= ?" + args.append(to) + sql += " ORDER BY id" + rows = self._conn.execute(sql, args).fetchall() + return True, {"rows": [dict(r) for r in rows]} + + def list_clients(self) -> tuple: + with self._lock: + rows = self._conn.execute( + "SELECT * FROM clients ORDER BY id").fetchall() + return True, {"clients": [self._client_record(r, True) for r in rows]} + + +# -------------------------------------------------------------------------- +# App fixtures +# -------------------------------------------------------------------------- + +try: # exercise the real core when issue #1 lands + from gitea_saas import billing_core as _real_core_module + HAS_REAL_CORE = hasattr(_real_core_module, "BillingCore") +except Exception: # pragma: no cover - depends on unmerged issue #1 + _real_core_module = None + HAS_REAL_CORE = False + + +@pytest.fixture() +def app(tmp_path: Path): + core = ReferenceCore(tmp_path / "test.db") + application = server.create_app(core, API_KEY) + application.config.update(TESTING=True) + try: + yield application + finally: + core.close() + + +@pytest.fixture() +def client(app): + return app.test_client() + + +def _post(client, url, body, key=API_KEY): + return client.post(url, json=body, headers={API_KEY_HEADER: key}) + + +# -------------------------------------------------------------------------- +# Auth / health +# -------------------------------------------------------------------------- + +def test_health_is_public(client): + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.get_json() == {"ok": True} + + +def test_missing_key_is_401(client): + resp = client.get("/api/clients") + assert resp.status_code == 401 + body = resp.get_json() + assert body["code"] == "unauthorized" + + +def test_wrong_key_is_401(client): + resp = client.get("/api/clients", headers={API_KEY_HEADER: "nope"}) + assert resp.status_code == 401 + assert resp.get_json()["code"] == "unauthorized" + + +def test_correct_key_is_200(client): + resp = client.get("/api/clients", headers=AUTH) + assert resp.status_code == 200 + + +# -------------------------------------------------------------------------- +# Client lifecycle +# -------------------------------------------------------------------------- + +def test_full_lifecycle_via_api(client): + # create + created = _post(client, "/api/clients", + {"name": "Acme", "tier": "enterprise", "meta": {"v": 1}}) + assert created.status_code == 201 + cid = created.get_json()["id"] + assert created.get_json()["balance"] == 0 + assert created.get_json()["name"] == "Acme" + assert created.get_json()["meta"] == {"v": 1} + + # credit + cred = _post(client, f"/api/clients/{cid}/credits", + {"amount_minor": 5000, "idempotency_key": "c1"}) + assert cred.status_code == 200 + assert cred.get_json()["balance"] == 5000 + + # get + got = client.get(f"/api/clients/{cid}", headers=AUTH) + assert got.status_code == 200 + assert got.get_json()["balance"] == 5000 + + # hold + held = _post(client, f"/api/clients/{cid}/holds", + {"amount_minor": 2000, "ref_type": "job", "ref_id": "j1"}) + assert held.status_code == 201 + hold_id = held.get_json()["hold_id"] + got = client.get(f"/api/clients/{cid}", headers=AUTH) + assert got.get_json()["balance"] == 3000 # 5000 - 2000 held + + # debit (up to held-away balance) + deb = _post(client, f"/api/clients/{cid}/debits", + {"amount_minor": 3000, "idempotency_key": "d1"}) + assert deb.status_code == 200 + assert deb.get_json()["balance"] == 0 # 5000 - 3000 - 2000(held) + + # release hold -> balance restored + rel = _post(client, f"/api/holds/{hold_id}/release", {}) + assert rel.status_code == 200 + assert rel.get_json()["released"] is True + got = client.get(f"/api/clients/{cid}", headers=AUTH) + assert got.get_json()["balance"] == 2000 + + # ledger shows everything + led = client.get(f"/api/clients/{cid}/ledger", headers=AUTH) + assert led.status_code == 200 + kinds = [r["kind"] for r in led.get_json()["rows"]] + assert kinds == ["credit", "debit"] + + +def test_debit_below_zero_is_409_and_no_change(client): + created = _post(client, "/api/clients", {"name": "Broke"}) + cid = created.get_json()["id"] + resp = _post(client, f"/api/clients/{cid}/debits", + {"amount_minor": 100, "idempotency_key": "d-lose"}) + assert resp.status_code == 409 + body = resp.get_json() + assert body["code"] == "negative_balance" + assert body["error"] + got = client.get(f"/api/clients/{cid}", headers=AUTH) + assert got.get_json()["balance"] == 0 + # idempotency key must not be burned by a rejected debit + resp2 = _post(client, f"/api/clients/{cid}/credits", + {"amount_minor": 50, "idempotency_key": "d-lose"}) + assert resp2.status_code == 200 + assert resp2.get_json()["balance"] == 50 + + +def test_duplicate_idempotency_key_no_double_apply(client): + created = _post(client, "/api/clients", {"name": "Dup"}) + cid = created.get_json()["id"] + first = _post(client, f"/api/clients/{cid}/credits", + {"amount_minor": 777, "idempotency_key": "same"}) + second = _post(client, f"/api/clients/{cid}/credits", + {"amount_minor": 777, "idempotency_key": "same"}) + assert first.status_code == second.status_code == 200 + assert first.get_json()["balance"] == second.get_json()["balance"] == 777 + assert second.get_json()["duplicate"] is True + got = client.get(f"/api/clients/{cid}", headers=AUTH) + assert got.get_json()["balance"] == 777 + + +def test_hold_insufficient_is_409(client): + created = _post(client, "/api/clients", {"name": "Tiny"}) + cid = created.get_json()["id"] + resp = _post(client, f"/api/clients/{cid}/holds", {"amount_minor": 10}) + assert resp.status_code == 409 + assert resp.get_json()["code"] == "insufficient_funds" + + +def test_hold_expiry_auto_releases(client, app): + created = _post(client, "/api/clients", {"name": "Expiry"}) + cid = created.get_json()["id"] + _post(client, f"/api/clients/{cid}/credits", + {"amount_minor": 1000, "idempotency_key": "e1"}) + held = _post(client, f"/api/clients/{cid}/holds", + {"amount_minor": 400, "ttl_s": 1}) + hold_id = held.get_json()["hold_id"] + assert client.get(f"/api/clients/{cid}", + headers=AUTH).get_json()["balance"] == 600 + # force expiry by rewriting timestamps back in time + core = app.config["CORE"] + past = (datetime.datetime.now(datetime.timezone.utc) + - datetime.timedelta(seconds=5)).isoformat() + with core._lock: + core._conn.execute("UPDATE holds SET expires_at = ? WHERE id = ?", + (past, hold_id)) + core._conn.commit() + got = client.get(f"/api/clients/{cid}", headers=AUTH) + assert got.get_json()["balance"] == 1000 # auto-released on read + + +def test_release_missing_hold_is_404(client): + resp = _post(client, "/api/holds/999999/release", {}) + assert resp.status_code == 404 + assert resp.get_json()["code"] == "not_found" + + +def test_unknown_client_is_404(client): + resp = client.get("/api/clients/424242", headers=AUTH) + assert resp.status_code == 404 + assert resp.get_json()["code"] == "not_found" + + +def test_ledger_filter_from_to(client): + created = _post(client, "/api/clients", {"name": "Filter"}) + cid = created.get_json()["id"] + _post(client, f"/api/clients/{cid}/credits", + {"amount_minor": 10, "idempotency_key": "f1"}) + _post(client, f"/api/clients/{cid}/credits", + {"amount_minor": 20, "idempotency_key": "f2"}) + # from=epoch start -> both rows; to=something future -> both rows + led = client.get(f"/api/clients/{cid}/ledger?from=2000-01-01T00:00:00", + headers=AUTH) + assert led.status_code == 200 + assert led.get_json()["count"] == 2 + + +def test_list_clients_admin(client): + _post(client, "/api/clients", {"name": "A"}) + _post(client, "/api/clients", {"name": "B"}) + resp = client.get("/api/clients", headers=AUTH) + assert resp.status_code == 200 + body = resp.get_json() + assert body["count"] == 2 + assert {c["name"] for c in body["clients"]} == {"A", "B"} + + +def test_errors_are_json_objects(client): + resp = _post(client, "/api/clients", {"amount_minor": "x"}) # bad body + assert resp.status_code == 400 + body = resp.get_json() + assert set(body) == {"error", "code"} + assert isinstance(body["error"], str) + + +def test_invalid_amount_is_400(client): + created = _post(client, "/api/clients", {"name": "Bad"}) + cid = created.get_json()["id"] + resp = _post(client, f"/api/clients/{cid}/credits", + {"amount_minor": -5, "idempotency_key": "neg"}) + assert resp.status_code == 400 + assert resp.get_json()["code"] == "invalid_request" + + +def test_missing_idempotency_key_is_400(client): + created = _post(client, "/api/clients", {"name": "NoKey"}) + cid = created.get_json()["id"] + resp = _post(client, f"/api/clients/{cid}/credits", {"amount_minor": 1}) + assert resp.status_code == 400 + + +def test_create_client_requires_name(client): + resp = _post(client, "/api/clients", {"tier": "standard"}) + assert resp.status_code == 400 + + +# -------------------------------------------------------------------------- +# Real-core integration (runs only once issue #1 has landed) +# -------------------------------------------------------------------------- + +@pytest.mark.skipif(not HAS_REAL_CORE, + reason="billing core (issue #1) not merged onto main yet") +def test_real_core_integration(tmp_path: Path): + core = _real_core_module.BillingCore(tmp_path / "real.db") + application = server.create_app(core, API_KEY) + application.config.update(TESTING=True) + c = application.test_client() + try: + created = c.post("/api/clients", json={"name": "Real"}, + headers=AUTH) + assert created.status_code == 201 + cid = created.get_json()["id"] + resp = c.post(f"/api/clients/{cid}/credits", + json={"amount_minor": 100, "idempotency_key": "r1"}, + headers=AUTH) + assert resp.status_code == 200 + assert resp.get_json()["balance"] == 100 + finally: + core.close() if hasattr(core, "close") else None -- 2.43.0