gitea-saas-core#41: client-facing surface — per-client keys, checkout, /api/me, self-serve work submission, Stripe webhook

- client_api.py: ClientKeyStore (SHA-256 hashed at rest, scoped, revocable),
  checkout with per-client rate limit, /api/me status, /api/me/issues submit,
  signature-authenticated /api/webhooks/stripe reusing the issue-#3 seam, and
  operator key-issuance routes; compound assembly guard replaces the billing
  operator before_request with identical operator-route semantics so scoped
  client endpoints are reachable and valid client keys get 403 on operator
  routes (cross-tenant + operator denials).
- auth.py: PUBLIC_PATHS + /api/webhooks/stripe; FORBIDDEN_BODY/
  forbidden_response; hash_client_key (no new compare_digest call site).
- schema.sql: client_api_keys table (hash + prefix only).
- app.py: mounts the client surface (stripe_builder/gitea injectable).
- tests/test_client_api.py (18 tests) + assembled-app coverage in test_app.py.
  Full suite: 293 passed, 1 skipped.
This commit is contained in:
Claude 2026-09-05 04:34:50 -05:00
parent 2ef1e668fa
commit f39343ea81
6 changed files with 1467 additions and 21 deletions

View File

@ -6,6 +6,7 @@ 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)
client API ``gitea_saas.client_api.register_client_routes`` (#41)
Assembly only: this module makes no behaviour changes to billing, factory,
dashboard or review. If composing them ever surfaces a genuine
@ -26,17 +27,20 @@ from __future__ import annotations
import tempfile
from pathlib import Path
from types import SimpleNamespace
from typing import Optional
from typing import Callable, Optional
from flask import Flask, jsonify, request
from .auth import PUBLIC_PATHS
from .billing_core import BillingCore
from .client_api import (ClientKeyStore, ClientStatus, ClientSurface,
register_client_routes)
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
from .stripe_client import build_stripe_client
# Canonical body for the unconfigured-deployment gate (status 503). Same
# ``{"error": {"code", "message"}}`` shape as ``auth.UNAUTHORIZED_BODY`` so
@ -90,18 +94,28 @@ def _unconfigured_guard(configured: bool):
return _guard
def create_app(config: Optional[ServerConfig] = None) -> Flask:
"""Build the ONE Flask app: billing + factory + dashboard mounted.
def create_app(config: Optional[ServerConfig] = None, *,
stripe_builder: Optional[Callable[[], object]] = None,
gitea: Optional[object] = None) -> Flask:
"""Build the ONE Flask app: billing + factory + dashboard + client surface.
``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).
``stripe_builder`` is a zero-arg callable returning a Stripe seam (mock
or real) for the client surface (issue #41); it defaults to the
env-driven ``stripe_client.build_stripe_client``, which is fail-closed:
with no credentials and no explicit ``SAAS_STRIPE_MODE=mock`` it raises,
the surface answers checkout/webhook with 503 ``stripe_unconfigured``,
and the app still starts. ``gitea`` defaults to a real ``GiteaClient``
(factory + client work-submission seam).
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).
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 ""
@ -114,10 +128,26 @@ def create_app(config: Optional[ServerConfig] = None) -> Flask:
# raise "IntakeFactory requires config.db_path" for the no-env case.
runtime = SimpleNamespace(db_path=db_path, api_key=api_key)
gitea_client = gitea if gitea is not None else GiteaClient()
app = create_billing_app(core, api_key)
register_factory_routes(app, core, GiteaClient(), runtime)
register_factory_routes(app, core, gitea_client, runtime)
mount_dashboard(app, core=core, db_path=db_path, api_key=api_key)
# Issue #41 client surface: per-client keys, checkout, /api/me,
# self-serve work submission and the Stripe webhook. Mounting it
# replaces the billing app's operator-key before_request with the
# compound assembly guard (see client_api.register_client_routes) whose
# operator-route semantics are identical — with the scoped client
# endpoints reachable and valid client keys answered 403 on operator
# routes (never 401, never passed through).
key_store = ClientKeyStore(db_path)
status = ClientStatus(core, db_path,
getattr(cfg, "debit_floor_minor", None))
surface = ClientSurface(core, key_store, status,
stripe_builder or build_stripe_client)
register_client_routes(app, surface, gitea_client, runtime)
guard = _unconfigured_guard(bool(api_key))
if guard is not None:
# Global before_request funcs run in list order; position 0 puts

View File

@ -1,21 +1,22 @@
"""Single X-API-Key auth path for the whole service (issue #24).
Every API-key front-end (``server.py``, ``delivery.py``, ``intake_factory.py``,
``dashboard.py``) delegates to this module. The header name, the constant-time
comparison and the 401 body are defined exactly once here; a front-end never
performs its own check or hardcodes the header literal.
``dashboard.py``, ``client_api.py``) delegates to this module; the header name, the
constant-time comparison and the 401 body live exactly once here (no front-end forks them).
``stripe_client.py`` verifies webhook *signatures* (a different secret on a
different verification path) and is explicitly out of scope for this issue —
it keeps its own constant-time signature check.
``stripe_client.py`` verifies webhook *signatures* (a different secret on a different
verification path) and is explicitly out of scope for this issue — it keeps its own signature check.
Public surface:
API_KEY_HEADER the only ``X-API-Key`` header literal in the package
PUBLIC_PATHS deliberately unauthenticated liveness/health routes
PUBLIC_PATHS deliberately unauthenticated liveness/health routes + the Stripe webhook (#41)
key_is_valid() constant-time, fail-closed check — the one API-key
comparison in the whole service
UNAUTHORIZED_BODY canonical 401 payload, byte-identical on every wire
unauthorized_response() Flask Response object carrying that body
FORBIDDEN_BODY canonical 403 payload (issue #41 cross-tenant denial)
forbidden_response() Flask Response object carrying that body
hash_client_key() SHA-256 hash for per-client keys (#41) — stored, never plaintext
"""
from __future__ import annotations
@ -23,19 +24,18 @@ from __future__ import annotations
import hmac
from typing import Optional
# The one place the HTTP header literal lives. A `git grep` for the quoted
# header name must match exactly this definition (acceptance criterion 2).
# The one place the HTTP header literal lives (acceptance criterion 2: one literal).
API_KEY_HEADER = "X-API-Key"
# Routes that need no API key. Every front-end guard reads this set, so a
# route's exposure is declared here, not inside one PR's before_request
# (composition finding, 2026-08-26): /health (server.py), /healthz
# (dashboard.py) and /api/factory/health (intake_factory.py) are all
# liveness routes and must stay reachable without a key.
# Routes that need no operator API key; every front-end guard reads this set,
# so exposure is declared here, not inside one PR's before_request (composition
# finding 2026-08-26): /health, /healthz, /api/factory/health are liveness
# routes; /api/webhooks/stripe (#41) is signature-authenticated.
PUBLIC_PATHS = frozenset({
"/health",
"/healthz",
"/api/factory/health",
"/api/webhooks/stripe",
})
# Canonical 401 payload. All front-ends return exactly this (Flask
@ -77,3 +77,62 @@ def unauthorized_response():
resp = jsonify(UNAUTHORIZED_BODY)
resp.status_code = 401
return resp
# ---------------------------------------------------------------------------
# Issue #41 additions — the client-facing surface's auth pieces.
#
# Deliberately placed BELOW key_is_valid/unauthorized_response so the issue
# #24 acceptance pin (exactly one compare_digest call site in this file, at
# auth.py:70) is untouched: per-client key verification adds NO comparison
# call site. A presented client key is hashed (hash_client_key) and resolved
# by indexed equality on the full 64-char digest in the client_api_keys
# table (client_api.ClientKeyStore.resolve_client_id). The digest is
# one-way, so even a timing leak on digest equality reveals nothing about
# the key — the discipline that matters is: full-length digest, and no
# plaintext ever stored or compared.
# ---------------------------------------------------------------------------
# Canonical 403 payload (issue #41): a VALID key presented against a route
# its tenant is not allowed to reach (another client's resources, or an
# operator-only route). Distinguished from 401 (no/wrong key) so a client
# can tell "authenticate" from "authenticated but forbidden".
FORBIDDEN_BODY = {
"error": {
"code": "FORBIDDEN",
"message": "this key is not authorized for this resource",
},
}
def forbidden_response():
"""Flask Response with the canonical 403 body (status 403, issue #41).
Used when a key is VALID but its tenant is not allowed on the requested
route: another client's resources or an operator-only route. The body
is byte-identical on every front-end so a client parses it once.
"""
from flask import jsonify
resp = jsonify(FORBIDDEN_BODY)
resp.status_code = 403
return resp
CLIENT_KEY_HASH_ALGORITHM = "sha256"
def hash_client_key(raw_key: str) -> str:
"""SHA-256 hex digest of a per-client API key (issue #41).
The ONLY place a client key is hashed. ``raw_key`` must be a
non-empty str (a non-str raises ``TypeError`` from ``hashlib`` — the
caller validates before hashing).
"""
import hashlib
if not isinstance(raw_key, str) or not raw_key:
raise ValueError("client key must be a non-empty string")
digest = hashlib.new(CLIENT_KEY_HASH_ALGORITHM)
digest.update(raw_key.encode("utf-8"))
return digest.hexdigest()

672
gitea_saas/client_api.py Normal file
View File

@ -0,0 +1,672 @@
"""Client-facing HTTP surface (issue #41): self-serve funding + work submission.
Until this module there was exactly one credential — the shared operator
``SAAS_API_KEY`` — and every route was operator/admin. This module adds the
first genuinely externally-reachable surface:
* **Per-client API keys** — ``ClientKeyStore``. Keys are random, scoped to
exactly one ``client_id``, stored ONLY as a SHA-256 hash (the plaintext is
returned exactly once, at issuance, then never seen again) and revocable.
The hash function lives in ``gitea_saas.auth`` (issue #41 extension) and
verification is digest-equality against the key table — this module does
not fork auth, it builds on it.
* **Checkout** — ``POST /api/checkout`` returns a Stripe Checkout URL for a
top-up, rate-limited per client. The webhook below is the ONLY credit
path: ``stripe_client.apply_payment_event`` (the issue-#3 seam) is reused,
never a second credit implementation.
* **Client status** — ``GET /api/me`` returns the caller's own balance,
queue counts, in-flight count and delivered count (scoped to the caller's
client_id by construction).
* **Work submission** — ``POST /api/me/issues`` submits work as the caller's
OWN client_id through the issue-#4 factory (funded -> Gitea issue,
unfunded/locked -> queued). A client never supplies another tenant's id.
* **Stripe webhook** — ``POST /api/webhooks/stripe``, authenticated by
Stripe's signature (never by an API key), credits idempotently.
Tenancy & guard composition (the subtle part)
---------------------------------------------
``server.create_app`` installs an operator-key ``before_request`` guard that
401s every non-``PUBLIC_PATHS`` route — it would make the client surface
unreachable, since client routes are authenticated by a DIFFERENT key class.
Mounting the client surface therefore REPLACES that single global guard
with a compound assembly guard (registered in ``register_client_routes``);
operator-route behaviour is preserved EXACTLY (same ``PUBLIC_PATHS`` set,
same ``auth.key_is_valid``, same canonical 401):
1. ``PUBLIC_PATHS`` (liveness + the signature-authenticated webhook) pass
straight through.
2. A valid client key on a ``CLIENT_PATHS`` route is stored in
``flask.g.client_id`` and allowed.
3. A valid client key ANYWHERE ELSE (``/api/clients``, ``/api/factory/*``,
``/dashboard``, key issuance) is forbidden: canonical 403 body.
4. No/invalid client key: client-surface routes get the canonical 401;
operator routes fall through to the operator check, which decides
exactly as before (the unconfigured-deployment 503 gate from
``app.create_app`` runs ahead of everything and still short-circuits
every non-public route first).
Consequences that are deliberate:
* The operator key never works on the client surface (401), and a client key
never works on operator routes (403).
* The operator key-issuance routes (``/api/clients/<id>/keys``) are NOT part
of the client surface — they stay behind the operator guard, and a client
key hitting them gets 403.
* The existing ``/api/factory/<id>/*`` routes stay operator-only. Clients
submit work through ``/api/me/issues`` instead, so the factory module is
untouched and its per-route operator check cannot be bypassed.
Fail-closed anywhere a secret could be at stake: an unconfigured Stripe
deployment answers checkout/webhook with 503 ``stripe_unconfigured`` (never
a fallback secret, never a 200); nothing in this module ever logs a key, a
Stripe secret, or a webhook payload.
Hermetic by construction (same convention as the other front-ends): the
billing core, key store and status provider are injected; the Stripe client
comes from an injected builder (the mock is selected only on explicit opt-in
per ``stripe_client.build_stripe_client``).
"""
from __future__ import annotations
import os
import re
import secrets
import sqlite3
import threading
import time
from collections import deque
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from flask import Flask, g, jsonify, request
from .auth import (API_KEY_HEADER, PUBLIC_PATHS, forbidden_response,
hash_client_key, key_is_valid, unauthorized_response)
from .intake_factory import (CoreContractError, IntakeFactory,
InvalidRequestError, UnknownClientError)
# Canonical prefix for generated client keys (display-friendly, never used
# for auth — the full key is required and only its hash is stored).
CLIENT_KEY_PREFIX = "saas_"
CLIENT_KEY_BYTES = 32
# The routes that belong to a client's OWN surface (self-scoped by the
# caller's key). A valid client key is allowed here and only here; on every
# other route it is forbidden (403). Keep in sync with the routes below.
CLIENT_PATHS = frozenset({
"/api/checkout",
"/api/me",
"/api/me/issues",
})
STRIPE_WEBHOOK_ROUTE = "/api/webhooks/stripe"
# Rate limit for POST /api/checkout per client (fixed per-client window).
DEFAULT_CHECKOUT_RATE_LIMIT = 5
DEFAULT_CHECKOUT_RATE_WINDOW_S = 60
# Mirror of intake_factory's repo-name rule (issue #4 contract).
_REPO_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")
# Idempotent DDL mirror of the schema.sql addition (same convention as
# intake_factory._FACTORY_DDL: this module can be constructed against a
# fresh file even before the billing core has run schema.sql).
_KEYS_DDL = """
CREATE TABLE IF NOT EXISTS client_api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id),
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_client_api_keys_client ON client_api_keys (client_id);
CREATE INDEX IF NOT EXISTS idx_client_api_keys_hash ON client_api_keys (key_hash);
"""
def _json_error(status: int, code: str, message: str):
"""Canonical structured error (same shape as auth.UNAUTHORIZED_BODY)."""
resp = jsonify({"error": {"code": code, "message": message}})
resp.status_code = status
return resp
class _RateLimiter:
"""Sliding-window request limiter, keyed by tenant (in-process).
Single-process service (gunicorn sync workers run one app instance per
worker); the limiter is per-process by design. ``allow(key)`` returns
``(allowed, retry_after_s)`` — retry_after is 0 when allowed.
"""
def __init__(self, limit: int, window_s: float):
self._limit = max(int(limit), 1)
self._window_s = float(window_s)
self._hits: Dict[Any, deque] = {}
self._lock = threading.Lock()
def allow(self, key: Any) -> tuple[bool, float]:
now = time.monotonic()
with self._lock:
dq = self._hits.setdefault(key, deque())
while dq and now - dq[0] >= self._window_s:
dq.popleft()
if len(dq) >= self._limit:
retry = self._window_s - (now - dq[0])
return False, max(retry, 1.0)
dq.append(now)
return True, 0.0
class ClientKeyStore:
"""Per-client API keys on the shared SQLite file: issue/list/revoke/resolve.
At-rest policy (acceptance criterion 2): ONLY ``hash_client_key`` output
is stored — the plaintext is returned once at issuance and never written.
A resolver looks the presented key up by its hash; a revoked or deleted
client's keys stop resolving. ``list()`` returns metadata only (id,
prefix, timestamps) — never hashes, never plaintext.
"""
def __init__(self, db_path):
self._conn = sqlite3.connect(str(Path(db_path)), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA foreign_keys=ON")
self._conn.executescript(_KEYS_DDL)
self._lock = threading.RLock()
# -- helpers ----------------------------------------------------------
def _client_exists(self, client_id: int) -> bool:
row = self._conn.execute(
"SELECT 1 FROM clients WHERE id = ?", (client_id,)).fetchone()
return row is not None
@staticmethod
def _generate() -> str:
return CLIENT_KEY_PREFIX + secrets.token_urlsafe(CLIENT_KEY_BYTES)
# -- operations -------------------------------------------------------
def issue(self, client_id: int) -> tuple[bool, dict]:
"""Create a key for ``client_id``; plaintext returned ONCE.
Returns ``(True, {"key_id", "client_id", "key", "prefix",
"created_at"})`` or ``(False, {"error": ...})`` for an unknown
client. The plaintext ``key`` is not stored and cannot be recovered.
"""
if isinstance(client_id, bool) or not isinstance(client_id, int) \
or client_id <= 0:
return False, {"error": {"code": "UNKNOWN_CLIENT",
"message": "client_id must be a positive integer"}}
with self._lock:
if not self._client_exists(client_id):
return False, {"error": {"code": "UNKNOWN_CLIENT",
"message": f"no client with id {client_id}"}}
raw = self._generate()
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
cur = self._conn.execute(
"INSERT INTO client_api_keys (client_id, key_hash, key_prefix,"
" created_at) VALUES (?, ?, ?, ?)",
(client_id, hash_client_key(raw), raw[:10], now))
self._conn.commit()
return True, {"key_id": cur.lastrowid, "client_id": client_id,
"key": raw, "prefix": raw[:10], "created_at": now}
def list_keys(self, client_id: int) -> tuple[bool, dict]:
"""Key metadata for a client (never hashes, never plaintext)."""
with self._lock:
if not self._client_exists(client_id):
return False, {"error": {"code": "UNKNOWN_CLIENT",
"message": f"no client with id {client_id}"}}
rows = self._conn.execute(
"SELECT id, key_prefix, created_at, revoked_at"
" FROM client_api_keys WHERE client_id = ? ORDER BY id",
(client_id,)).fetchall()
keys = [{"key_id": int(r["id"]), "prefix": r["key_prefix"],
"created_at": r["created_at"], "revoked_at": r["revoked_at"]}
for r in rows]
return True, {"client_id": client_id, "keys": keys, "count": len(keys)}
def revoke(self, client_id: int, key_id: int) -> tuple[bool, dict]:
"""Revoke exactly one key that BELONGS to ``client_id`` (scoped)."""
with self._lock:
if not self._client_exists(client_id):
return False, {"error": {"code": "UNKNOWN_CLIENT",
"message": f"no client with id {client_id}"}}
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
cur = self._conn.execute(
"UPDATE client_api_keys SET revoked_at = ?"
" WHERE id = ? AND client_id = ? AND revoked_at IS NULL",
(now, key_id, client_id))
self._conn.commit()
if cur.rowcount == 0:
return False, {"error": {"code": "KEY_NOT_FOUND",
"message": "no active key with that id for this client"}}
return True, {"key_id": key_id, "client_id": client_id,
"revoked_at": now, "revoked": True}
def revoke_all(self, client_id: int) -> tuple[bool, dict]:
"""Revoke every active key of ``client_id`` (idempotent)."""
with self._lock:
if not self._client_exists(client_id):
return False, {"error": {"code": "UNKNOWN_CLIENT",
"message": f"no client with id {client_id}"}}
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
cur = self._conn.execute(
"UPDATE client_api_keys SET revoked_at = ?"
" WHERE client_id = ? AND revoked_at IS NULL",
(now, client_id))
self._conn.commit()
return True, {"client_id": client_id, "revoked_count": cur.rowcount}
def resolve_client_id(self, provided_key: Optional[str]) -> Optional[int]:
"""The client_id owning ``provided_key``, or None (fail-closed).
Hashes the presented key and looks the row up by hash; revoked keys
and keys whose client no longer exists never resolve. Returns None
for a missing/malformed key, a revoked key, or an unknown client.
"""
if not isinstance(provided_key, str) or not provided_key:
return None
try:
digest = hash_client_key(provided_key)
except ValueError:
return None
try:
row = self._conn.execute(
"SELECT k.client_id FROM client_api_keys k"
" JOIN clients c ON c.id = k.client_id"
" WHERE k.key_hash = ? AND k.revoked_at IS NULL",
(digest,)).fetchone()
except sqlite3.Error:
return None
return int(row["client_id"]) if row is not None else None
class ClientStatus:
"""Client-visible status: balance + queue/in-flight/delivered counts.
Reads the shared SQLite file like ``Dashboard`` does (its own read
connection); balance comes from the billing core (single source of
truth, holds included). Definitions (documented in the API):
* ``queued_items`` — ``pending_issues`` rows still waiting in the
intake queue (unfunded/locked submits, issue #4).
* ``in_flight_items`` — ``pending_issues`` rows the factory has marked
``shipped`` (flushed from the queue into Gitea, awaiting
merge/delivery). A direct funded submit is created in Gitea without
a local row and is therefore not counted here — the factory tracks
neither it nor a delivery-complete state, so this count is defined by
the data the system records.
* ``delivered_count`` — distinct delivery debits in the ledger
(``ref_type`` ``pr``/``delivery`` — the only DB record of a delivery).
"""
DEFAULT_DEBIT_FLOOR_MINOR = 500
def __init__(self, core: Any, db_path, debit_floor_minor: Optional[int] = None):
self._core = core
self._conn = sqlite3.connect(str(Path(db_path)))
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
if debit_floor_minor is None:
raw = os.environ.get("SAAS_DEBIT_FLOOR_MINOR", "").strip()
debit_floor_minor = int(raw) if raw else self.DEFAULT_DEBIT_FLOOR_MINOR
self._floor = debit_floor_minor
def _table_exists(self, name: str) -> bool:
row = self._conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
(name,)).fetchone()
return row is not None
def summary(self, client_id: int) -> tuple[bool, dict]:
ok, details = self._core.get_client(client_id, include_balance=True)
if not ok or not isinstance(details, dict):
return False, details if isinstance(details, dict) \
else {"error": {"code": "UNKNOWN_CLIENT",
"message": f"no client with id {client_id}"}}
cid = int(details.get("id", client_id))
balance = details.get("balance")
locked = False
if self._table_exists("factory_locks"):
try:
locked = self._conn.execute(
"SELECT 1 FROM factory_locks WHERE client_id = ?",
(cid,)).fetchone() is not None
except Exception:
locked = False
if locked:
status = "locked"
elif isinstance(balance, int) and balance < self._floor:
status = "overdue"
else:
status = str(details.get("status") or "active")
queued = in_flight = delivered = 0
if self._table_exists("pending_issues"):
try:
row = self._conn.execute(
"SELECT status, COUNT(*) AS n FROM pending_issues"
" WHERE client_id = ? GROUP BY status", (cid,)).fetchall()
counts = {str(r["status"]): int(r["n"]) for r in row}
queued = counts.get("queued", 0)
in_flight = counts.get("shipped", 0)
except Exception:
queued = in_flight = 0
try:
delivered = int(self._conn.execute(
"SELECT COUNT(DISTINCT ref_id) FROM ledger"
" WHERE client_id = ? AND kind = 'debit'"
" AND ref_type IN ('pr', 'delivery') AND ref_id IS NOT NULL",
(cid,)).fetchone()[0])
except Exception:
delivered = 0
return True, {
"client_id": cid,
"name": details.get("name"),
"tier": details.get("tier"),
"status": status,
"balance_minor": balance,
"currency": "USD",
"queued_items": queued,
"in_flight_items": in_flight,
"delivered_count": delivered,
}
class ClientSurface:
"""State shared by the client routes: guard, keys, status, Stripe. """
def __init__(self, core: Any, key_store: ClientKeyStore,
status: ClientStatus,
stripe_builder: Callable[[], Any],
checkout_rate: int = DEFAULT_CHECKOUT_RATE_LIMIT,
checkout_window_s: float = DEFAULT_CHECKOUT_RATE_WINDOW_S):
self.core = core
self.keys = key_store
self.status = status
self.stripe_builder = stripe_builder
self._limiter = _RateLimiter(checkout_rate, checkout_window_s)
# -- request-time helpers --------------------------------------------
def stripe(self) -> Optional[Any]:
"""A usable Stripe client, or None when unconfigured (fail-closed).
The builder is env-driven (``stripe_client.build_stripe_client`` by
default): a deployment without Stripe credentials, or without an
EXPLICIT mock opt-in, gets None here — the routes answer 503, never
a fallback secret and never a 200 with data.
"""
try:
return self.stripe_builder()
except Exception:
return None
def limiter_allow(self, client_id: int) -> tuple[bool, float]:
return self._limiter.allow(client_id)
# -- the tenant-aware decision (used by the compound assembly guard) ---
def check_client_key(self, path: str) -> Optional[tuple]:
"""Decide the client-surface part of a request's auth.
Returns ``None`` when the request is not a client-surface matter
(operator routes are left to the operator check), or a tuple:
* ``("forbid", Flask-Response)`` — a VALID client key presented
outside its own surface (403, cross-tenant / operator route), or
a client-surface path without a valid client key (401);
* ``("allowed", client_id)`` — a valid client key on its own
surface; the caller stores the client_id for the route.
"""
provided = request.headers.get(API_KEY_HEADER, "")
client_id = self.keys.resolve_client_id(provided)
if client_id is not None:
if path in CLIENT_PATHS: # the key's own surface
return ("allowed", client_id)
return ("forbid", forbidden_response()) # wrong tenant/route
if path in CLIENT_PATHS: # client surface needs a client key
return ("forbid", unauthorized_response())
return None # operator routes: their check decides
def _map_key_store_error(details: dict):
code = (details.get("error") or {}).get("code")
message = (details.get("error") or {}).get("message") or "key operation failed"
if code == "UNKNOWN_CLIENT":
return _json_error(404, "unknown_client", message)
if code == "KEY_NOT_FOUND":
return _json_error(404, "key_not_found", message)
return _json_error(500, "key_store_error", message)
def _map_factory_error(exc: Exception):
"""Map IntakeFactory domain errors (mirror of intake_factory's mapper)."""
if isinstance(exc, UnknownClientError):
return _json_error(404, "unknown_client", str(exc))
if isinstance(exc, (InvalidRequestError, ValueError)):
return _json_error(400, "invalid_request", str(exc))
if isinstance(exc, CoreContractError):
return _json_error(500, "core_contract", str(exc))
return _json_error(500, "factory_error", f"{type(exc).__name__}: {exc}")
def register_client_routes(app: Flask, surface: ClientSurface,
gitea: Any, config: Any) -> Flask:
"""Mount the client-facing surface on an existing Flask app.
``surface`` carries core/keys/status/Stripe; ``gitea`` and ``config``
feed the per-request ``IntakeFactory`` for ``/api/me/issues`` (same
seam the issue-#4 factory routes use).
Registers the tenant-aware guard AHEAD of every other before_request
(including the operator-key guard installed by ``server.create_app``) —
see the module docstring: valid client keys must be answered 403 on
operator routes, and the client surface must be reachable even though
the operator guard would otherwise 401 it.
"""
core = surface.core
# -- the tenant-aware guard --------------------------------------------
# server.create_app installs an operator-key before_request that 401s
# every non-PUBLIC_PATHS route — including the client surface, whose
# auth is a DIFFERENT key class. Mounting the client surface therefore
# REPLACES that single global guard with a compound assembly guard:
# 1. PUBLIC_PATHS (liveness + the signature-authenticated webhook)
# pass straight through;
# 2. the client-surface decision wins when it is one (403 for a
# valid client key off its surface / on operator routes, 401 on
# the client surface without a valid key);
# 3. otherwise the operator check runs with EXACTLY server.create_app's
# semantics (same PUBLIC_PATHS set, same auth.key_is_valid, same
# canonical 401 body) — no behaviour change for operator routes.
# The unconfigured-deployment 503 gate (app.py) is inserted at position
# 0 AFTER this, so it still short-circuits everything non-public first.
app.before_request_funcs.setdefault(None, []).clear()
def _assembly_guard():
path = request.path.rstrip("/")
if path in PUBLIC_PATHS:
return None
decision = surface.check_client_key(path)
if decision is not None:
outcome, payload = decision
if outcome == "forbid":
return payload # 403 / 401 decision (see above)
g.client_id = payload # allowed on the client's own surface
return None
# Operator routes: server.create_app's check, preserved verbatim.
sent = request.headers.get(API_KEY_HEADER, "")
expected = app.config.get("API_KEY", "")
if key_is_valid(sent, expected):
return None
return unauthorized_response()
app.before_request_funcs.setdefault(None, []).insert(0, _assembly_guard)
# -- self-serve checkout ---------------------------------------------
@app.post("/api/checkout")
def checkout():
client_id = g.get("client_id")
if client_id is None:
return unauthorized_response()
allowed, retry_after = surface.limiter_allow(client_id)
if not allowed:
resp = _json_error(429, "rate_limited",
"too many checkout requests; retry shortly")
resp.headers["Retry-After"] = str(int(retry_after))
return resp
body = request.get_json(silent=True)
if not isinstance(body, dict):
return _json_error(400, "invalid_request",
"request body must be a JSON object")
amount = body.get("amount_minor")
if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0:
return _json_error(400, "invalid_request",
"'amount_minor' must be a positive integer (minor units)")
currency = body.get("currency") or "usd"
if (not isinstance(currency, str) or len(currency) != 3
or not currency.isalpha()):
return _json_error(400, "invalid_request",
"'currency' must be a 3-letter code")
success_url = body.get("success_url")
cancel_url = body.get("cancel_url")
for name, value in (("success_url", success_url),
("cancel_url", cancel_url)):
if value is not None and (
not isinstance(value, str)
or not re.match(r"^https?://", value.strip())):
return _json_error(400, "invalid_request",
f"'{name}' must be an absolute http(s) URL")
stripe = surface.stripe()
if stripe is None:
return _json_error(503, "stripe_unconfigured",
"Stripe is not configured on this deployment (fail-closed)")
try:
result = stripe.create_checkout_session(
client_id, amount, currency.lower(),
success_url.strip() if isinstance(success_url, str) else None,
cancel_url.strip() if isinstance(cancel_url, str) else None)
except Exception:
return _json_error(503, "stripe_error",
"checkout session could not be created")
resp = jsonify(result)
resp.status_code = 201
return resp
# -- Stripe webhook (signature-authenticated; NOT an API-key route) ---
@app.post(STRIPE_WEBHOOK_ROUTE)
def stripe_webhook():
stripe = surface.stripe()
if stripe is None:
return _json_error(503, "stripe_unconfigured",
"Stripe is not configured on this deployment (fail-closed)")
payload = request.get_data()
signature = request.headers.get("Stripe-Signature", "")
try:
event = stripe.process_webhook(payload, signature)
except Exception:
# Signature failures and malformed payloads are indistinguishable
# on purpose: never leak whether the signature was the problem.
return _json_error(400, "invalid_signature",
"webhook signature verification failed")
from .stripe_client import apply_payment_event
ok, details = apply_payment_event(core, event)
if not ok:
message = ((details or {}).get("error") or {}).get("message") \
or "event could not be applied"
return _json_error(400, "event_rejected", message)
# Success path: idempotent credit via the issue-#3 seam. The full
# payload is never logged and never echoed.
return jsonify({"received": True,
"applied": bool(details.get("applied", True)),
"event_id": event.get("event_id")})
# -- client status & self-serve work submission -----------------------
@app.get("/api/me")
def me():
client_id = g.get("client_id")
if client_id is None:
return unauthorized_response()
ok, details = surface.status.summary(client_id)
if not ok:
return _json_error(404, "unknown_client",
f"no client with id {client_id}")
return jsonify(details)
@app.post("/api/me/issues")
def me_submit_issue():
client_id = g.get("client_id")
if client_id is None:
return unauthorized_response()
body = request.get_json(silent=True)
if not isinstance(body, dict):
return _json_error(400, "invalid_request",
"request body must be a JSON object")
repo = (body.get("repo") or "").strip()
title = (body.get("title") or "").strip()
acceptance_body = str(body.get("acceptance_body") or "")
labels = body.get("labels")
if not isinstance(labels, list):
labels = None
labels = [str(label) for label in labels] if labels else None
if not _REPO_NAME_RE.match(repo):
return _json_error(400, "invalid_request",
f"invalid repo name: {repo!r}")
if not title:
return _json_error(400, "invalid_request",
"issue title must be a non-empty string")
try:
factory = IntakeFactory(core, gitea, config)
result = factory.submit_issue(client_id, repo, title,
acceptance_body, labels)
except Exception as exc:
return _map_factory_error(exc)
resp = jsonify(result)
resp.status_code = 202 if not result.get("delivered") else 201
return resp
# -- operator-only key management (behind the operator guard) ---------
# These are NOT client-surface routes: the tenant-aware guard above
# forbids a client key here (403), and the operator guard requires the
# shared SAAS_API_KEY.
@app.post("/api/clients/<int:client_id>/keys")
def issue_client_key(client_id: int):
ok, details = surface.keys.issue(client_id)
if not ok:
return _map_key_store_error(details)
resp = jsonify(details)
resp.status_code = 201
return resp
@app.get("/api/clients/<int:client_id>/keys")
def list_client_keys(client_id: int):
ok, details = surface.keys.list_keys(client_id)
if not ok:
return _map_key_store_error(details)
return jsonify(details)
@app.delete("/api/clients/<int:client_id>/keys/<int:key_id>")
def revoke_client_key(client_id: int, key_id: int):
ok, details = surface.keys.revoke(client_id, key_id)
if not ok:
return _map_key_store_error(details)
return jsonify(details)
@app.delete("/api/clients/<int:client_id>/keys")
def revoke_all_client_keys(client_id: int):
ok, details = surface.keys.revoke_all(client_id)
if not ok:
return _map_key_store_error(details)
return jsonify(details)
return app

View File

@ -42,6 +42,24 @@ CREATE TABLE IF NOT EXISTS holds (
released_at TEXT
);
-- Per-client API keys (issue #41): the client-facing surface's credentials.
-- Only the SHA-256 hash of a key is stored -- never the plaintext. A row is
-- scoped to exactly one client, so a key can only ever resolve to its owner's
-- client_id (cross-tenant denial is enforced by the client surface using this
-- scope). revoked_at NULL = active; a revoked key fails resolution and is
-- never returned by any listing (prefix + created_at only, for display).
CREATE TABLE IF NOT EXISTS client_api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id),
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_client_api_keys_client ON client_api_keys (client_id);
CREATE INDEX IF NOT EXISTS idx_client_api_keys_hash ON client_api_keys (key_hash);
-- Balance / listing lookups.
CREATE INDEX IF NOT EXISTS idx_ledger_client_created ON ledger (client_id, created_at, id);
CREATE INDEX IF NOT EXISTS idx_ledger_client_kind ON ledger (client_id, kind);

View File

@ -21,7 +21,8 @@ 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.auth import (API_KEY_HEADER, FORBIDDEN_BODY,
UNAUTHORIZED_BODY)
from gitea_saas.config import ServerConfig
API_KEY = "test-api-key-secret"
@ -86,6 +87,20 @@ def test_url_map_contains_dashboard_and_liveness_routes(app):
assert expected in rules, f"missing route {expected}"
def test_url_map_contains_client_surface_routes(app):
"""Issue #41: the assembled app mounts the whole client surface."""
rules = _rules(app)
for expected in (
"/api/checkout",
"/api/me",
"/api/me/issues",
"/api/webhooks/stripe",
"/api/clients/<int:client_id>/keys",
"/api/clients/<int:client_id>/keys/<int:key_id>",
):
assert expected in rules, f"missing client route {expected}"
# ---------------------------------------------------------------------------
# Acceptance 2: no SAAS_API_KEY -> app starts, authed routes fail closed 503
# ---------------------------------------------------------------------------
@ -104,6 +119,13 @@ def test_unset_api_key_fails_closed_503(tmp_path, monkeypatch):
assert c.get("/api/dashboard/summary").status_code == 503
assert c.post("/api/factory/provision",
json={"client_id": 1}).status_code == 503
# issue #41: the client surface is authed too -> 503, not 200.
assert c.get("/api/me").status_code == 503
assert c.post("/api/checkout", json={"amount_minor": 100}).status_code == 503
assert c.post("/api/me/issues", json={}).status_code == 503
# ...but the signature-authenticated webhook is a PUBLIC_PATHS route
# (no Stripe configured here -> fail-closed 503, never a 200).
assert c.post("/api/webhooks/stripe", data=b"{}").status_code == 503
# liveness routes in auth.PUBLIC_PATHS stay reachable without a key
for path in ("/health", "/healthz", "/api/factory/health"):
@ -147,6 +169,100 @@ def test_wrong_key_is_canonical_401(client):
assert resp.get_json() == UNAUTHORIZED_BODY
# ---------------------------------------------------------------------------
# Issue #41 through the REAL assembled app: authenticated client path,
# operator/cross-tenant denials, checkout -> webhook credit
# ---------------------------------------------------------------------------
class _FakeGitea:
def create_org_repo(self, name):
pass
def create_issue(self, repo, title, body, labels=None):
return 1
def _make_client_app(tmp_path):
from gitea_saas.stripe_client import MockStripeClient
application = create_app(
_cfg(tmp_path),
stripe_builder=lambda: MockStripeClient(webhook_signature="test-sig"),
gitea=_FakeGitea())
application.config.update(TESTING=True)
return application
def test_assembled_app_authenticated_client_path_and_denials(tmp_path):
"""The assembled app serves the client surface and denies misuse.
Acceptance: client A's key works on ITS OWN surface through the real
composed app; it gets the canonical 403 on /api/clients, on client B's
/api/factory/* resources and on the dashboard; the operator key never
reaches the client surface. Checkout -> signature-verified webhook ->
balance credit (idempotent) also runs through the assembled app.
"""
import json as _json
application = _make_client_app(tmp_path)
c = application.test_client()
def op_post(path, body=None):
return c.post(path, json=body or {}, headers=AUTH)
# Provision + keys.
a_id = op_post("/api/clients", {"name": "AsmClientA"}).get_json()["id"]
b_id = op_post("/api/clients", {"name": "AsmClientB"}).get_json()["id"]
key_a = c.post(f"/api/clients/{a_id}/keys",
headers=AUTH).get_json()["key"]
key_b = c.post(f"/api/clients/{b_id}/keys",
headers=AUTH).get_json()["key"]
# Client A's own surface through the assembled app.
me = c.get("/api/me", headers={API_KEY_HEADER: key_a})
assert me.status_code == 200
assert me.get_json()["client_id"] == a_id
# Checkout -> webhook -> credit (idempotent), through the assembled app.
checkout = c.post("/api/checkout",
json={"amount_minor": 3000},
headers={API_KEY_HEADER: key_a})
assert checkout.status_code == 201
event = {"id": "evt_asm_1", "type": "payment.succeeded",
"data": {"object": {"metadata": {"client_id": str(a_id)},
"amount_total": 3000}}}
payload = _json.dumps(event).encode()
wh = c.post("/api/webhooks/stripe", data=payload,
content_type="application/json",
headers={"Stripe-Signature": "test-sig"})
assert wh.status_code == 200
me = c.get("/api/me", headers={API_KEY_HEADER: key_a}).get_json()
assert me["balance_minor"] == 3000
replay = c.post("/api/webhooks/stripe", data=payload,
content_type="application/json",
headers={"Stripe-Signature": "test-sig"})
assert replay.status_code == 200
me = c.get("/api/me", headers={API_KEY_HEADER: key_a}).get_json()
assert me["balance_minor"] == 3000 # exactly once
# Cross-tenant + operator denials (canonical 403 body).
for path in (f"/api/factory/{b_id}/lock",
f"/api/factory/{b_id}/issues",
f"/api/clients/{b_id}",
f"/api/clients/{b_id}/keys"):
resp = c.post(path, json={}, headers={API_KEY_HEADER: key_a})
assert resp.status_code == 403, path
assert resp.get_json() == FORBIDDEN_BODY, path
assert c.get("/api/clients",
headers={API_KEY_HEADER: key_a}).status_code == 403
assert c.get("/dashboard",
headers={API_KEY_HEADER: key_a}).status_code == 403
# Client B still sees only itself.
assert c.get("/api/me", headers={API_KEY_HEADER: key_b}).get_json()[
"client_id"] == b_id
# The operator key never works on the client surface.
assert c.get("/api/me", headers=AUTH).status_code == 401
# ---------------------------------------------------------------------------
# Entrypoints exist and are wired to the same main()
# ---------------------------------------------------------------------------

551
tests/test_client_api.py Normal file
View File

@ -0,0 +1,551 @@
"""Hermetic tests for the client-facing surface (issue #41).
Per-client API keys (hashed at rest, scoped, revocable), self-serve
checkout (rate-limited), the Stripe webhook credit path (idempotent via
the issue-#3 seam), ``GET /api/me`` client status, self-serve work
submission, and the cross-tenant / operator-route denial guarantees.
Zero network: Flask test client + temp SQLite DB + MockStripeClient +
FakeGitea. Run with:
python -m pytest tests/test_client_api.py -q
"""
import json
import sys
from pathlib import Path
from typing import Optional
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from gitea_saas.auth import (API_KEY_HEADER, FORBIDDEN_BODY, # noqa: E402
UNAUTHORIZED_BODY, hash_client_key)
from gitea_saas.billing_core import BillingCore # noqa: E402
from gitea_saas.client_api import (ClientKeyStore, ClientStatus, # noqa: E402
ClientSurface, register_client_routes)
from gitea_saas.config import ServerConfig # noqa: E402
from gitea_saas.stripe_client import MockStripeClient # noqa: E402
API_KEY = "operator-test-key-41"
AUTH = {API_KEY_HEADER: API_KEY}
# Explicit mock signature: tests only, never a production default.
MOCK_SIG = "test-webhook-sig-41"
class FakeGitea:
"""In-memory create_org_repo/create_issue seam (factory contract)."""
def __init__(self):
self.repos = set()
self.issues = []
def create_org_repo(self, name):
self.repos.add(name)
def create_issue(self, repo, title, body, labels=None):
number = len(self.issues) + 1
self.issues.append({"repo": repo, "title": title, "body": body,
"labels": labels, "number": number})
return number
def _mock_stripe_builder():
return MockStripeClient(webhook_signature=MOCK_SIG)
def _cfg(tmp_path: Path, api_key: Optional[str] = API_KEY) -> ServerConfig:
return ServerConfig(api_key=api_key, port=8080, db_path=tmp_path / "saas.db")
def _webhook_payload(event_id="evt_41_1", client_id=1, amount=2500):
return {"id": event_id, "type": "payment.succeeded",
"data": {"object": {"metadata": {"client_id": str(client_id)},
"amount_total": amount}}}
@pytest.fixture()
def client_factory(tmp_path):
"""Build ONE assembled app per test (cached); returns a zero-arg builder."""
state = {}
def build():
if "app" in state:
return state["app"], state["gitea"]
from gitea_saas.app import create_app
gitea = FakeGitea()
application = create_app(_cfg(tmp_path, API_KEY),
stripe_builder=_mock_stripe_builder,
gitea=gitea)
application.config.update(TESTING=True)
state["app"] = application
state["gitea"] = gitea
return application, gitea
build()
return build
@pytest.fixture()
def client(client_factory):
application, _ = client_factory()
return application.test_client()
@pytest.fixture()
def gitea(client_factory):
return client_factory()[1]
# ---------------------------------------------------------------------------
# helpers: provision clients + issue keys through the operator API
# ---------------------------------------------------------------------------
def _create_client(client, name: str) -> int:
resp = client.post("/api/clients", json={"name": name}, headers=AUTH)
assert resp.status_code == 201
return resp.get_json()["id"]
def _issue_key(client, client_id: int) -> dict:
resp = client.post(f"/api/clients/{client_id}/keys", headers=AUTH)
assert resp.status_code == 201
return resp.get_json()
def _client_header(raw_key: str) -> dict:
return {API_KEY_HEADER: raw_key}
# ---------------------------------------------------------------------------
# ClientKeyStore unit tests (acceptance criterion 2: hashed at rest)
# ---------------------------------------------------------------------------
def test_keys_stored_hashed_plaintext_never_in_db(tmp_path):
core = BillingCore(tmp_path / "saas.db")
ok, rec = core.create_client("HashCo")
store = ClientKeyStore(tmp_path / "saas.db")
ok, issued = store.issue(rec["id"])
assert ok
raw = issued["key"]
# The plaintext key is not anywhere in the DB — not as a value, not as
# a column. The stored material is hash + display prefix only.
rows = store._conn.execute(
"SELECT key_hash, key_prefix FROM client_api_keys").fetchall()
assert len(rows) == 1
stored_hash, stored_prefix = rows[0]["key_hash"], rows[0]["key_prefix"]
assert hash_client_key(raw) == stored_hash
assert stored_hash != raw
assert stored_prefix != raw # prefix is a truncated display hint
assert raw not in stored_hash
# Dump the whole DB as text: plaintext must not appear anywhere.
blob = " ".join(str(v) for row in store._conn.execute(
"SELECT * FROM client_api_keys") for v in row)
assert raw not in blob
def test_resolve_client_id_scoped_and_revoked(tmp_path):
core = BillingCore(tmp_path / "saas.db")
_, a = core.create_client("Alice")
_, b = core.create_client("Bob")
store = ClientKeyStore(tmp_path / "saas.db")
_, ka = store.issue(a["id"])
_, kb = store.issue(b["id"])
assert store.resolve_client_id(ka["key"]) == a["id"]
assert store.resolve_client_id(kb["key"]) == b["id"]
assert store.resolve_client_id("saas_wrong") is None
assert store.resolve_client_id("") is None
assert store.resolve_client_id(None) is None
# Revoke A's key only; B's keeps resolving.
ok, _ = store.revoke(a["id"], ka["key_id"])
assert ok
assert store.resolve_client_id(ka["key"]) is None
assert store.resolve_client_id(kb["key"]) == b["id"]
def test_revoke_is_scoped_no_cross_tenant_revoke(tmp_path):
core = BillingCore(tmp_path / "saas.db")
_, a = core.create_client("Alice")
_, b = core.create_client("Bob")
store = ClientKeyStore(tmp_path / "saas.db")
_, ka = store.issue(a["id"])
ok, details = store.revoke(b["id"], ka["key_id"])
assert not ok and (details.get("error") or {}).get("code") == "KEY_NOT_FOUND"
assert store.resolve_client_id(ka["key"]) == a["id"] # still active
ok, details = store.revoke(a["id"], 999999)
assert not ok and (details.get("error") or {}).get("code") == "KEY_NOT_FOUND"
def test_list_and_revoke_all_never_expose_secrets(tmp_path):
core = BillingCore(tmp_path / "saas.db")
_, a = core.create_client("Alice")
store = ClientKeyStore(tmp_path / "saas.db")
_, k1 = store.issue(a["id"])
_, k2 = store.issue(a["id"])
ok, listing = store.list_keys(a["id"])
assert ok and listing["count"] == 2
for item in listing["keys"]:
assert "key" not in item and "key_hash" not in item
ok, res = store.revoke_all(a["id"])
assert ok and res["revoked_count"] == 2
assert store.resolve_client_id(k1["key"]) is None
assert store.resolve_client_id(k2["key"]) is None
def test_issue_unknown_client(tmp_path):
BillingCore(tmp_path / "saas.db")
store = ClientKeyStore(tmp_path / "saas.db")
ok, details = store.issue(999)
assert not ok and (details.get("error") or {}).get("code") == "UNKNOWN_CLIENT"
# ---------------------------------------------------------------------------
# Operator key management HTTP routes
# ---------------------------------------------------------------------------
def test_operator_issues_and_revokes_keys(client):
cid = _create_client(client, "KeyCo")
issued = _issue_key(client, cid)
assert issued["client_id"] == cid
assert issued["key"].startswith("saas_")
assert issued["prefix"] == issued["key"][:10]
assert len(issued["key"]) > 20
# Listing shows metadata only.
listing = client.get(f"/api/clients/{cid}/keys", headers=AUTH)
assert listing.status_code == 200
assert listing.get_json()["count"] == 1
assert "key" not in listing.get_json()["keys"][0]
# The key works on the client surface.
me = client.get("/api/me", headers=_client_header(issued["key"]))
assert me.status_code == 200
assert me.get_json()["client_id"] == cid
# Revoke via the operator route: key stops working.
rev = client.delete(f"/api/clients/{cid}/keys/{issued['key_id']}",
headers=AUTH)
assert rev.status_code == 200
assert client.get("/api/me",
headers=_client_header(issued["key"])).status_code == 401
def test_key_routes_require_operator_key(client):
cid = _create_client(client, "LockedCo")
assert client.post(f"/api/clients/{cid}/keys").status_code == 401
assert client.get(f"/api/clients/{cid}/keys").status_code == 401
# A CLIENT key (other tenant) is 403 on key issuance routes.
key = _issue_key(client, cid)
other = _create_client(client, "OtherCo")
resp = client.post(f"/api/clients/{other}/keys",
headers=_client_header(key["key"]))
assert resp.status_code == 403
assert resp.get_json() == FORBIDDEN_BODY
# ---------------------------------------------------------------------------
# Tenancy: a client key never reaches another client's resources, and never
# reaches an operator route (acceptance criterion 1 + security note)
# ---------------------------------------------------------------------------
def test_cross_tenant_and_operator_denials(client):
a_id = _create_client(client, "TenantA")
b_id = _create_client(client, "TenantB")
key_a = _issue_key(client, a_id)["key"]
# Client A's key on client B's resources: 403 everywhere.
for path in (
f"/api/factory/{b_id}/issues",
f"/api/factory/{b_id}/lock",
f"/api/factory/{b_id}/unlock",
f"/api/clients/{b_id}",
f"/api/clients/{b_id}/ledger",
f"/api/clients/{b_id}/credits",
):
resp = client.post(path, json={}, headers=_client_header(key_a))
assert resp.status_code == 403, path
assert resp.get_json() == FORBIDDEN_BODY
# Operator routes: /api/clients, /api/factory/*, /dashboard.
assert client.get("/api/clients", headers=_client_header(key_a)) \
.status_code == 403
assert client.post("/api/factory/provision",
json={"client_id": b_id},
headers=_client_header(key_a)).status_code == 403
assert client.get("/dashboard", headers=_client_header(key_a)) \
.status_code == 403
assert client.get("/api/dashboard/summary", headers=_client_header(key_a)) \
.status_code == 403
# And the operator key never works on the client surface.
assert client.get("/api/me", headers=AUTH).status_code == 401
assert client.get("/api/me").get_json() == UNAUTHORIZED_BODY
assert client.post("/api/checkout", json={"amount_minor": 100},
headers=AUTH).status_code == 401
def test_each_client_sees_only_its_own_me(client):
a_id = _create_client(client, "SelfScopeA")
b_id = _create_client(client, "SelfScopeB")
key_a = _issue_key(client, a_id)["key"]
key_b = _issue_key(client, b_id)["key"]
me_a = client.get("/api/me", headers=_client_header(key_a)).get_json()
me_b = client.get("/api/me", headers=_client_header(key_b)).get_json()
assert me_a["client_id"] == a_id
assert me_b["client_id"] == b_id
assert me_a["name"] == "SelfScopeA"
assert me_b["name"] == "SelfScopeB"
# ---------------------------------------------------------------------------
# Checkout (rate-limited) + webhook credit (idempotent) — acceptance 3
# ---------------------------------------------------------------------------
def test_checkout_creates_session_scoped_to_caller(client, tmp_path):
cid = _create_client(client, "CheckoutCo")
key = _issue_key(client, cid)["key"]
resp = client.post("/api/checkout",
json={"amount_minor": 2500,
"success_url": "https://example.test/ok"},
headers=_client_header(key))
assert resp.status_code == 201
body = resp.get_json()
assert set(body) == {"checkout_url", "session_id"}
assert body["checkout_url"].startswith("https://checkout.stripe.com/mock/")
assert body["session_id"].startswith("cs_mock_")
def test_checkout_webhook_credit_exactly_once(client, tmp_path):
"""Acceptance 3: checkout -> webhook -> balance credit, replay no-op."""
cid = _create_client(client, "FundCo")
key = _issue_key(client, cid)["key"]
checkout = client.post("/api/checkout", json={"amount_minor": 2500},
headers=_client_header(key))
assert checkout.status_code == 201
payload = _webhook_payload(client_id=cid, amount=2500)
headers = {"Stripe-Signature": MOCK_SIG}
first = client.post("/api/webhooks/stripe",
data=json.dumps(payload), # raw bytes path
content_type="application/json", headers=headers)
assert first.status_code == 200
assert first.get_json()["applied"] is True
balance = client.get("/api/me", headers=_client_header(key)).get_json()
assert balance["balance_minor"] == 2500
# Replayed webhook (same event id): exactly once — no double credit.
replay = client.post("/api/webhooks/stripe",
data=json.dumps(payload),
content_type="application/json", headers=headers)
assert replay.status_code == 200
balance_after = client.get("/api/me",
headers=_client_header(key)).get_json()
assert balance_after["balance_minor"] == 2500
def test_webhook_rejects_bad_signature_and_bad_payload(client):
cid = _create_client(client, "WebhookCo")
payload = json.dumps(_webhook_payload(client_id=cid)).encode()
resp = client.post("/api/webhooks/stripe", data=payload,
content_type="application/json",
headers={"Stripe-Signature": "wrong-sig"})
assert resp.status_code == 400
assert resp.get_json()["error"]["code"] == "invalid_signature"
# No signature header at all: same generic 400.
resp = client.post("/api/webhooks/stripe", data=payload,
content_type="application/json")
assert resp.status_code == 400
def test_checkout_validation(client):
cid = _create_client(client, "BadCo")
key = _issue_key(client, cid)["key"]
h = _client_header(key)
assert client.post("/api/checkout", json={"amount_minor": 0},
headers=h).status_code == 400
assert client.post("/api/checkout", json={"amount_minor": "50"},
headers=h).status_code == 400
assert client.post("/api/checkout", json={"amount_minor": 100,
"currency": "too-long"},
headers=h).status_code == 400
assert client.post("/api/checkout",
json={"amount_minor": 100,
"success_url": "javascript:alert(1)"},
headers=h).status_code == 400
assert client.post("/api/checkout", json="nope",
headers=h).status_code == 400
def test_checkout_rate_limited_per_client(tmp_path):
"""Rate-limit /api/checkout per client (security note)."""
db = tmp_path / "saas.db"
core = BillingCore(db)
ok, rec = core.create_client("RatedCo")
store = ClientKeyStore(db)
ok, issued = store.issue(rec["id"])
import flask
from gitea_saas.client_api import ClientStatus
app = flask.Flask(__name__)
app.config["API_KEY"] = API_KEY
surface = ClientSurface(core, store, ClientStatus(core, db),
_mock_stripe_builder,
checkout_rate=2, checkout_window_s=60)
register_client_routes(app, surface,
gitea=FakeGitea(),
config=type("C", (), {"db_path": db})())
app.config.update(TESTING=True)
c = app.test_client()
h = _client_header(issued["key"])
assert c.post("/api/checkout", json={"amount_minor": 100},
headers=h).status_code == 201
assert c.post("/api/checkout", json={"amount_minor": 100},
headers=h).status_code == 201
third = c.post("/api/checkout", json={"amount_minor": 100}, headers=h)
assert third.status_code == 429
assert third.get_json()["error"]["code"] == "rate_limited"
assert third.headers.get("Retry-After") is not None
# Other client's limiter is its own (no cross-tenant throttling).
ok, rec2 = core.create_client("OtherRated")
ok, issued2 = store.issue(rec2["id"])
assert c.post("/api/checkout", json={"amount_minor": 100},
headers=_client_header(issued2["key"])).status_code == 201
def test_stripe_unconfigured_fails_closed(tmp_path, monkeypatch):
"""No Stripe mode/creds -> checkout & webhook 503, never a 200."""
monkeypatch.delenv("SAAS_STRIPE_MODE", raising=False)
monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False)
monkeypatch.delenv("STRIPE_WEBHOOK_SECRET", raising=False)
from gitea_saas.app import create_app
application = create_app(_cfg(tmp_path, API_KEY), gitea=FakeGitea())
application.config.update(TESTING=True)
c = application.test_client()
cid = c.post("/api/clients", json={"name": "NoStripe"},
headers=AUTH).get_json()["id"]
key = c.post(f"/api/clients/{cid}/keys", headers=AUTH).get_json()["key"]
checkout = c.post("/api/checkout", json={"amount_minor": 100},
headers=_client_header(key))
assert checkout.status_code == 503
assert checkout.get_json()["error"]["code"] == "stripe_unconfigured"
webhook = c.post("/api/webhooks/stripe",
data=json.dumps(_webhook_payload()).encode(),
content_type="application/json",
headers={"Stripe-Signature": "anything"})
assert webhook.status_code == 503
# ---------------------------------------------------------------------------
# /api/me status (balance, queued, in-flight, delivered)
# ---------------------------------------------------------------------------
def test_me_reports_balance_and_counts(client, gitea, tmp_path):
cid = _create_client(client, "StatusCo")
key = _issue_key(client, cid)["key"]
# Unfunded submit -> queued (no Gitea issue created).
resp = client.post("/api/me/issues",
json={"repo": "statusco-repo",
"title": "First task",
"acceptance_body": "do it"},
headers=_client_header(key))
assert resp.status_code == 202
assert resp.get_json()["delivered"] is False
me = client.get("/api/me", headers=_client_header(key)).get_json()
assert me["queued_items"] == 1
assert me["in_flight_items"] == 0
assert me["delivered_count"] == 0
assert me["balance_minor"] == 0
# Fund + lock: submits now queue; unlock flushes them to Gitea, which
# is what the factory records as "shipped" (in-flight, awaiting merge).
core = BillingCore(tmp_path / "saas.db")
ok, _ = core.credit(cid, 10000, "fund-1")
assert ok
assert client.post(f"/api/factory/{cid}/lock", json={},
headers=AUTH).status_code == 200
resp = client.post("/api/me/issues",
json={"repo": "statusco-repo",
"title": "Second task",
"acceptance_body": "do it too"},
headers=_client_header(key))
assert resp.status_code == 202
assert resp.get_json()["reason"] == "locked"
me = client.get("/api/me", headers=_client_header(key)).get_json()
assert me["queued_items"] == 2 # unfunded + locked
assert me["in_flight_items"] == 0
flush = client.post(f"/api/factory/{cid}/unlock", json={}, headers=AUTH)
assert flush.status_code == 200
assert flush.get_json()["flushed"] == 2
assert len(gitea.issues) == 2
me = client.get("/api/me", headers=_client_header(key)).get_json()
assert me["queued_items"] == 0
assert me["in_flight_items"] == 2 # shipped to Gitea, not delivered
assert me["balance_minor"] == 10000
# Delivery debit (what delivery.py writes on merge) -> delivered count.
ok, _ = core.debit(cid, 500, "deliver:statusco-repo:1:sha",
ref_type="pr", ref_id="statusco-repo#1")
assert ok
me = client.get("/api/me", headers=_client_header(key)).get_json()
assert me["delivered_count"] == 1
assert me["balance_minor"] == 9500
def test_me_issues_validation_and_scoping(client, tmp_path):
cid = _create_client(client, "SubmitCo")
other = _create_client(client, "OtherSubmit")
key = _issue_key(client, cid)["key"]
other_key = _issue_key(client, other)["key"]
h = _client_header(key)
assert client.post("/api/me/issues", json={"repo": "../evil",
"title": "x"},
headers=h).status_code == 400
assert client.post("/api/me/issues", json={"repo": "good-repo",
"title": ""},
headers=h).status_code == 400
# The submit is ALWAYS scoped to the caller — a queued row can only
# ever belong to the key's own client_id (factory enforces existence).
resp = client.post("/api/me/issues",
json={"repo": "submitco-repo", "title": "Task"},
headers=_client_header(other_key))
assert resp.status_code == 202
me_other = client.get("/api/me", headers=_client_header(other_key)).get_json()
assert me_other["queued_items"] == 1
me_self = client.get("/api/me", headers=h).get_json()
assert me_self["queued_items"] == 0
# ---------------------------------------------------------------------------
# Assembled-app reachability + fail-closed 503 (no SAAS_API_KEY)
# ---------------------------------------------------------------------------
def test_assembled_app_has_client_routes(client_factory, tmp_path, monkeypatch):
monkeypatch.delenv("SAAS_API_KEY", raising=False)
from gitea_saas.app import create_app
application = create_app(_cfg(tmp_path, api_key=None), gitea=FakeGitea())
application.config.update(TESTING=True)
c = application.test_client()
# No SAAS_API_KEY: every authed route (client surface included) is 503.
assert c.get("/api/me").status_code == 503
assert c.post("/api/checkout", json={"amount_minor": 100}).status_code == 503
assert c.post("/api/me/issues", json={}).status_code == 503