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:
parent
2ef1e668fa
commit
f39343ea81
@ -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
|
||||
|
||||
@ -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
672
gitea_saas/client_api.py
Normal 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
|
||||
@ -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);
|
||||
|
||||
@ -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
551
tests/test_client_api.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user