Stripe adapter (mockable seam: checkout + webhook -> idempotent credit) (#3) #11

Merged
pbwraith merged 3 commits from issue-3 into main 2026-08-26 09:09:49 +02:00
2 changed files with 557 additions and 0 deletions

246
gitea_saas/stripe_client.py Normal file
View File

@ -0,0 +1,246 @@
"""Stripe adapter as a mockable seam (issue #3).
Real implementation behind env keys; deterministic mock for tests and CI.
No live Stripe key needed in CI, no network in the mock path. The real
client imports the ``stripe`` library lazily, so this module loads (and
tests run) without it.
Interface (duck-typed, no HTTP):
* ``create_checkout_session(client_id, amount_minor, currency='usd',
success_url, cancel_url) -> {"checkout_url", "session_id"}``
* ``process_webhook(payload: bytes, signature: str)
-> {"event_id", "type", "client_id"?, "amount_minor"?}``
raises ``StripeSignatureError`` on an invalid signature.
Credentials resolve FAIL-CLOSED (bounce fix): the mock is selected only on
an explicit opt-in (``mock=True`` or ``SAAS_STRIPE_MODE=mock``) and never as
the fallback for absent configuration. With the mode unset or ``live``,
missing/empty ``STRIPE_SECRET_KEY`` / ``STRIPE_WEBHOOK_SECRET`` raise
``StripeConfigError`` at construction, naming the missing variable (name
only, never the value). The mock requires an explicitly supplied
``webhook_signature`` -- there is no published default.
Payments tie-in (server endpoint itself is issue #4): a
``payment.succeeded`` event is applied as
``core.credit(client_id, amount_minor, idempotency_key=event_id)`` via
``apply_payment_event``; duplicate webhook deliveries (same ``event_id``)
are no-ops thanks to the billing core's idempotency.
"""
from __future__ import annotations
import json
import hmac
import os
from typing import Optional
class StripeSignatureError(Exception):
"""Raised when a webhook signature fails verification."""
class StripeConfigError(Exception):
"""Raised when Stripe credentials cannot be resolved (fail-closed).
Carries only the names of the missing configuration, never its value.
"""
class MockStripeClient:
"""Deterministic in-memory Stripe adapter. Never touches the network.
Records every checkout session and processed webhook event so tests can
assert on them. Signature check: any signature that is not the
configured ``webhook_signature`` raises ``StripeSignatureError``. The
signature is REQUIRED -- it has no default, so a real deployment can
never inherit a published constant.
"""
def __init__(self, webhook_signature: str,
checkout_base_url: str = "https://checkout.stripe.com/mock"):
if not isinstance(webhook_signature, str) or not webhook_signature:
raise StripeConfigError(
"MockStripeClient requires an explicit webhook_signature")
self.checkout_base_url = checkout_base_url.rstrip("/")
self.webhook_signature = webhook_signature
self.created_checkouts: list[dict] = []
self.processed_events: list[dict] = []
# -- checkout ----------------------------------------------------------
def create_checkout_session(self, client_id: int, amount_minor: int,
currency: str = "usd",
success_url: Optional[str] = None,
cancel_url: Optional[str] = None) -> dict:
session_id = f"cs_mock_{client_id}_{amount_minor}_{len(self.created_checkouts) + 1}"
checkout_url = f"{self.checkout_base_url}/{session_id}"
self.created_checkouts.append({
"client_id": client_id, "amount_minor": amount_minor, "currency": currency,
"session_id": session_id, "checkout_url": checkout_url,
"success_url": success_url, "cancel_url": cancel_url,
})
return {"checkout_url": checkout_url, "session_id": session_id}
# -- webhook -----------------------------------------------------------
def process_webhook(self, payload: bytes, signature: str) -> dict:
if (not isinstance(signature, str)
or not hmac.compare_digest(signature, self.webhook_signature)):
raise StripeSignatureError("webhook signature verification failed")
data = json.loads(payload.decode("utf-8"))
event = self._map_event(data)
self.processed_events.append(event)
return event
@staticmethod
def _map_event(data: dict) -> dict:
"""Normalize a Stripe webhook payload to {event_id, type, ...}."""
event_id = data.get("id", "")
etype = data.get("type", "")
event: dict = {"event_id": event_id, "type": etype,
"client_id": None, "amount_minor": None}
obj = (data.get("data") or {}).get("object") or {}
if etype == "payment.succeeded":
metadata = obj.get("metadata") or {}
event["client_id"] = metadata.get("client_id")
event["amount_minor"] = obj.get("amount_total")
return event
class StripeClient:
"""Real Stripe adapter (requires the ``stripe`` python package).
Only used when ``STRIPE_SECRET_KEY`` and ``STRIPE_WEBHOOK_SECRET`` are
both set (see ``build_stripe_client``). CI runs mock-only.
"""
def __init__(self, secret_key: str, webhook_secret: str):
self.secret_key = secret_key
self.webhook_secret = webhook_secret
def _stripe(self):
try:
import stripe # lazy: module must load without the package
except ImportError as exc: # pragma: no cover - env not present in CI
raise RuntimeError(
"stripe python library is not installed; cannot use the real StripeClient"
) from exc
stripe.api_key = self.secret_key
return stripe
def create_checkout_session(self, client_id: int, amount_minor: int,
currency: str = "usd",
success_url: Optional[str] = None,
cancel_url: Optional[str] = None) -> dict:
stripe = self._stripe()
session = stripe.checkout.Session.create(
mode="payment",
line_items=[{
"price_data": {
"currency": currency,
"unit_amount": amount_minor,
"product_data": {"name": f"gitea-saas client {client_id} top-up"},
},
"quantity": 1,
}],
success_url=success_url,
cancel_url=cancel_url,
metadata={"client_id": str(client_id)},
)
return {"checkout_url": session.url, "session_id": session.id}
def process_webhook(self, payload: bytes, signature: str) -> dict:
stripe = self._stripe()
try:
event = stripe.Webhook.construct_event(payload, signature, self.webhook_secret)
except Exception as exc: # signature verification failure (any stripe error)
raise StripeSignatureError(str(exc)) from exc
obj = event["data"]["object"]
plain = {
"id": event["id"],
"type": event["type"],
"data": {"object": {
"metadata": dict(obj.get("metadata") or {}),
"amount_total": obj.get("amount_total"),
}},
}
return MockStripeClient._map_event(plain)
def build_stripe_client(secret_key: Optional[str] = None,
webhook_secret: Optional[str] = None,
mock: Optional[bool] = None,
webhook_signature: Optional[str] = None) -> object:
"""Factory: mock only on explicit opt-in; otherwise fail closed.
Bounce fix: the mock is NEVER the fallback for absent configuration.
It is selected only when ``mock=True`` or ``SAAS_STRIPE_MODE=mock``;
the mock's ``webhook_signature`` must be supplied explicitly (it has
no default, so a deployment cannot inherit a published constant).
With the mode unset or ``live`` (or any value other than ``mock``),
``STRIPE_SECRET_KEY`` and ``STRIPE_WEBHOOK_SECRET`` are both required:
a missing or empty variable raises :class:`StripeConfigError` naming
the variable (its name only -- never its value). Explicit arguments
win over environment variables.
"""
mode = (os.environ.get("SAAS_STRIPE_MODE") or "").strip().lower()
if mock is None:
mock = mode == "mock"
if mock:
if not isinstance(webhook_signature, str) or not webhook_signature:
raise StripeConfigError(
"mock mode requires an explicit webhook_signature "
"(there is no default; supply it to build_stripe_client)")
return MockStripeClient(webhook_signature=webhook_signature)
secret_key = secret_key if secret_key is not None else os.environ.get("STRIPE_SECRET_KEY")
webhook_secret = (webhook_secret if webhook_secret is not None
else os.environ.get("STRIPE_WEBHOOK_SECRET"))
missing = [name for name, value in (("STRIPE_SECRET_KEY", secret_key),
("STRIPE_WEBHOOK_SECRET", webhook_secret))
if not value]
if missing:
raise StripeConfigError(
"missing required Stripe credential(s): " + ", ".join(missing)
+ " (set them, or opt into mock mode explicitly via "
"SAAS_STRIPE_MODE=mock / mock=True)")
# Both values are non-empty strings here (checked above); narrow for
# the static checker so no None can reach the real client.
assert secret_key is not None and webhook_secret is not None
return StripeClient(secret_key=secret_key, webhook_secret=webhook_secret)
def apply_payment_event(core, event: dict) -> tuple[bool, dict]:
"""Server tie-in: apply a processed webhook event through the billing core.
``payment.succeeded`` -> ``core.credit(client_id, amount_minor,
idempotency_key=event_id)``. Duplicate webhook deliveries with the same
``event_id`` are no-ops (billing-core idempotency), never double-credits.
Other event types are ignored with ``(True, {"applied": False})``.
Returns the billing core's ``(ok, details)`` shape; errors are
structured, never raised.
"""
if not isinstance(event, dict) or event.get("type") != "payment.succeeded":
return True, {"applied": False, "reason": "ignored event type"}
event_id = event.get("event_id")
client_id = event.get("client_id")
amount_minor = event.get("amount_minor")
if not event_id or client_id is None or amount_minor is None:
return False, {"error": {
"code": "INCOMPLETE_EVENT",
"message": "payment.succeeded event missing event_id/client_id/amount_minor",
}}
try:
client_id = int(client_id)
amount_minor = int(amount_minor)
except (TypeError, ValueError):
return False, {"error": {
"code": "INCOMPLETE_EVENT",
"message": "payment.succeeded client_id/amount_minor must be numeric",
}}
ok, details = core.credit(client_id, amount_minor, event_id,
ref_type="stripe-event", ref_id=event_id)
if not ok:
return ok, {"applied": False, **details}
return ok, {"applied": True, **details}

311
tests/test_stripe_client.py Normal file
View File

@ -0,0 +1,311 @@
"""Hermetic tests for the Stripe adapter seam (issue #3).
Mock-only, zero network. Run with:
python -m pytest tests/test_stripe_client.py -q
"""
import json
import sys
from pathlib import Path
import pytest
# PEP 420 namespace package: make the project root importable regardless of
# how pytest was invoked.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from gitea_saas.billing_core import BillingCore # noqa: E402
from gitea_saas.stripe_client import ( # noqa: E402
MockStripeClient,
StripeClient,
StripeConfigError,
StripeSignatureError,
apply_payment_event,
build_stripe_client,
)
# Explicit mock signature for the hermetic suite. The point of the bounce
# fix: this constant lives in TESTS ONLY; the production client never has
# a default signature.
MOCK_SIG = "test-mock-secret"
WEBHOOK_PAYLOAD = {
"id": "evt_1A2B3C",
"type": "payment.succeeded",
"data": {
"object": {
"id": "pi_xyz",
"metadata": {"client_id": 7},
"amount_total": 2500,
"currency": "usd",
}
},
}
def payload_bytes(data=None) -> bytes:
return json.dumps(data or WEBHOOK_PAYLOAD).encode("utf-8")
def member_payload(event_id, event_type, client_id=None, amount=None):
return {"id": event_id, "type": event_type,
"data": {"object": {"metadata": {"client_id": client_id},
"amount_total": amount}}}
# ---------------------------------------------------------------------------
# Checkout sessions
# ---------------------------------------------------------------------------
def test_mock_creates_checkout_session_with_correct_fields():
client = MockStripeClient(webhook_signature=MOCK_SIG)
result = client.create_checkout_session(
client_id=7, amount_minor=2500, currency="usd",
success_url="https://example.test/success", cancel_url="https://example.test/cancel",
)
assert set(result) == {"checkout_url", "session_id"}
assert result["session_id"] == "cs_mock_7_2500_1"
assert result["checkout_url"].endswith("/" + result["session_id"])
assert client.created_checkouts == [{
"client_id": 7, "amount_minor": 2500, "currency": "usd",
"session_id": "cs_mock_7_2500_1",
"checkout_url": f"https://checkout.stripe.com/mock/cs_mock_7_2500_1",
"success_url": "https://example.test/success",
"cancel_url": "https://example.test/cancel",
}]
# Deterministic: next session id increments.
second = client.create_checkout_session(7, 2500)
assert second["session_id"] == "cs_mock_7_2500_2"
# ---------------------------------------------------------------------------
# Webhook mapping + signature check
# ---------------------------------------------------------------------------
def test_handler_maps_payment_succeeded_fields():
client = MockStripeClient(webhook_signature=MOCK_SIG)
event = client.process_webhook(payload_bytes(), client.webhook_signature)
assert event == {"event_id": "evt_1A2B3C", "type": "payment.succeeded",
"client_id": 7, "amount_minor": 2500}
assert client.processed_events == [event]
def test_bad_signature_raises_signature_error():
client = MockStripeClient(webhook_signature=MOCK_SIG)
with pytest.raises(StripeSignatureError):
client.process_webhook(payload_bytes(), "wrong-signature")
assert client.processed_events == [] # nothing recorded
def test_non_payment_events_map_without_credit_fields():
client = MockStripeClient(webhook_signature=MOCK_SIG)
event = client.process_webhook(
payload_bytes(member_payload("evt_9", "checkout.session.completed")),
client.webhook_signature,
)
assert event == {"event_id": "evt_9", "type": "checkout.session.completed",
"client_id": None, "amount_minor": None}
# ---------------------------------------------------------------------------
# payment.succeeded -> idempotent credit tie-in
# ---------------------------------------------------------------------------
def test_payment_succeeded_credits_once_and_duplicate_delivery_is_noop(tmp_path):
core = BillingCore(tmp_path / "billing.db")
ok, c = core.create_client("stripe-user")
assert ok
client = MockStripeClient(webhook_signature=MOCK_SIG)
payload = payload_bytes(member_payload("evt_credit", "payment.succeeded",
client_id=c["id"], amount=2000))
ok, details = apply_payment_event(core, client.process_webhook(payload, client.webhook_signature))
assert ok and details["applied"] is True
assert details["amount_minor"] == 2000
assert details["balance"] == 2000
assert details["client_id"] == c["id"]
# Same event_id re-delivered: no double credit.
ok, details = apply_payment_event(core, client.process_webhook(payload, client.webhook_signature))
assert ok and details["applied"] is True
assert details["replayed"] is True
assert details["balance"] == 2000
ok, ledger = core.list_ledger(c["id"])
assert ok and ledger["count"] == 1 # one credit row, applied once
entry = ledger["entries"][0]
assert entry["kind"] == "credit" and entry["idempotency_key"] == "evt_credit"
def test_apply_payment_event_ignores_non_payment_events(tmp_path):
core = BillingCore(tmp_path / "billing.db")
ok, c = core.create_client("stripe-user")
assert ok
client = MockStripeClient(webhook_signature=MOCK_SIG)
event = client.process_webhook(
payload_bytes(member_payload("evt_1", "checkout.session.completed",
client_id=c["id"], amount=5000)),
client.webhook_signature,
)
ok, details = apply_payment_event(core, event)
assert ok and details["applied"] is False
ok, ledger = core.list_ledger(c["id"])
assert ok and ledger["count"] == 0
def test_apply_payment_event_unknown_client_structured_error(tmp_path):
core = BillingCore(tmp_path / "billing.db")
core.create_client("other")
client = MockStripeClient(webhook_signature=MOCK_SIG)
event = client.process_webhook(
payload_bytes(member_payload("evt_u", "payment.succeeded", client_id=999, amount=100)),
client.webhook_signature,
)
ok, details = apply_payment_event(core, event)
assert ok is False and details["applied"] is False
assert details["error"]["code"] == "UNKNOWN_CLIENT"
def test_apply_payment_event_incomplete_event_structured_error(tmp_path):
core = BillingCore(tmp_path / "billing.db")
ok, c = core.create_client("stripe-user")
assert ok
client = MockStripeClient(webhook_signature=MOCK_SIG)
# Missing amount_total.
event = client.process_webhook(
payload_bytes({"id": "evt_i", "type": "payment.succeeded",
"data": {"object": {"metadata": {"client_id": c["id"]}}}}),
client.webhook_signature,
)
ok, details = apply_payment_event(core, event)
assert ok is False and details["error"]["code"] == "INCOMPLETE_EVENT"
def test_apply_payment_event_coerces_string_metadata_client_id(tmp_path):
core = BillingCore(tmp_path / "billing.db")
ok, c = core.create_client("stripe-user")
assert ok
client = MockStripeClient(webhook_signature=MOCK_SIG)
# Stripe metadata values arrive as strings; the tie-in must coerce.
payload = payload_bytes(member_payload("evt_s", "payment.succeeded",
client_id=str(c["id"]), amount=750))
event = client.process_webhook(payload, client.webhook_signature)
ok, details = apply_payment_event(core, event)
assert ok and details["applied"] is True and details["balance"] == 750
# ---------------------------------------------------------------------------
# Factory: fail-closed credential resolution (bounce fix)
# ---------------------------------------------------------------------------
def test_missing_credentials_raise_without_mock_optin(monkeypatch):
"""No env keys and no explicit mock opt-in -> construction raises,
naming the missing variables without leaking any value."""
monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False)
monkeypatch.delenv("STRIPE_WEBHOOK_SECRET", raising=False)
monkeypatch.delenv("SAAS_STRIPE_MODE", raising=False)
with pytest.raises(StripeConfigError) as excinfo:
build_stripe_client(secret_key="sk_test_SUPER_SECRET")
message = str(excinfo.value)
assert "STRIPE_WEBHOOK_SECRET" in message
assert "sk_test_SUPER_SECRET" not in message # name only, never the value
# Neither key at all -> both named.
with pytest.raises(StripeConfigError) as excinfo2:
build_stripe_client()
message2 = str(excinfo2.value)
assert "STRIPE_SECRET_KEY" in message2 and "STRIPE_WEBHOOK_SECRET" in message2
def test_only_one_of_two_keys_raises(monkeypatch):
monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False)
monkeypatch.delenv("STRIPE_WEBHOOK_SECRET", raising=False)
monkeypatch.delenv("SAAS_STRIPE_MODE", raising=False)
monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_abc")
with pytest.raises(StripeConfigError) as excinfo:
build_stripe_client()
assert "STRIPE_WEBHOOK_SECRET" in str(excinfo.value)
assert "sk_test_abc" not in str(excinfo.value)
monkeypatch.delenv("STRIPE_SECRET_KEY")
monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_abc")
with pytest.raises(StripeConfigError) as excinfo2:
build_stripe_client()
assert "STRIPE_SECRET_KEY" in str(excinfo2.value)
assert "whsec_abc" not in str(excinfo2.value)
def test_live_mode_still_works_when_both_keys_set(monkeypatch):
monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_abc")
monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_abc")
monkeypatch.setenv("SAAS_STRIPE_MODE", "live")
client = build_stripe_client()
assert isinstance(client, StripeClient)
assert client.secret_key == "sk_test_abc"
assert client.webhook_secret == "whsec_abc"
def test_explicit_mock_optin_with_explicit_signature_works(monkeypatch):
"""The suite's seam survives: explicit opt-in + explicit signature."""
monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False)
monkeypatch.delenv("STRIPE_WEBHOOK_SECRET", raising=False)
monkeypatch.delenv("SAAS_STRIPE_MODE", raising=False)
client = build_stripe_client(mock=True, webhook_signature=MOCK_SIG)
assert isinstance(client, MockStripeClient)
assert client.webhook_signature == MOCK_SIG
# And the seam functions end to end.
event = client.process_webhook(payload_bytes(), MOCK_SIG)
assert event["event_id"] == "evt_1A2B3C"
def test_env_mock_mode_requires_explicit_signature(monkeypatch):
monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False)
monkeypatch.delenv("STRIPE_WEBHOOK_SECRET", raising=False)
monkeypatch.setenv("SAAS_STRIPE_MODE", "mock")
# Opt-in via env, but no signature -> fail closed (no published default).
with pytest.raises(StripeConfigError):
build_stripe_client()
client = build_stripe_client(webhook_signature=MOCK_SIG)
assert isinstance(client, MockStripeClient)
assert client.webhook_signature == MOCK_SIG
def test_mock_without_signature_fails_closed():
# No default exists any more: omitting the signature is a hard
# TypeError at construction (fail-closed; nothing to inherit).
with pytest.raises(TypeError):
MockStripeClient() # noqa: E1120 - deliberately missing the arg
with pytest.raises(StripeConfigError):
MockStripeClient(webhook_signature=None)
with pytest.raises(StripeConfigError):
MockStripeClient(webhook_signature="")
with pytest.raises(StripeConfigError):
build_stripe_client(mock=True)
def test_forged_webhook_regression_balance_unchanged(tmp_path, monkeypatch):
"""Bounce regression: the exact forged-webhook scenario fails closed.
With credentials absent (and no mock opt-in) construction RAISES, so a
deployment can never silently degrade to a public-constant verifier.
Even on an explicitly-opted-in mock, a webhook signed with the old
published constant is rejected and the balance stays exactly 0 --
assert the balance, not just the exception, so a future refactor
cannot credit and then raise.
"""
monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False)
monkeypatch.delenv("STRIPE_WEBHOOK_SECRET", raising=False)
monkeypatch.delenv("SAAS_STRIPE_MODE", raising=False)
with pytest.raises(StripeConfigError):
build_stripe_client()
core = BillingCore(tmp_path / "billing.db")
ok, c = core.create_client("target")
assert ok
# An operator-chosen mock (this deployment's own secret, not a constant).
client = MockStripeClient(webhook_signature="operator-chosen-secret")
forged = payload_bytes(member_payload("evt_forged", "payment.succeeded",
client_id=c["id"], amount=500000))
with pytest.raises(StripeSignatureError):
event = client.process_webhook(forged, "mock-webhook-secret") # old constant
apply_payment_event(core, event) # pragma: no cover - never reached
ok, balance = core.get_balance(c["id"])
assert ok and balance["balance"] == 0 # unchanged: nothing was credited