Delivery receipts: idempotent debit on merge + client lock (#7) #18
311
gitea_saas/delivery.py
Normal file
311
gitea_saas/delivery.py
Normal file
@ -0,0 +1,311 @@
|
||||
"""Delivery receipts (issue #7): idempotent debit on merge + client lock.
|
||||
|
||||
Money half of the delivery loop. On a merged PR in a client repo this
|
||||
module:
|
||||
|
||||
* debits the client's ledger exactly once -- the idempotency key is derived
|
||||
from ``repo`` + ``pr_number`` + ``head_sha``, so duplicate webhook
|
||||
deliveries can never double-apply a debit or double-post a receipt;
|
||||
* posts a delivery receipt comment on the PR/issue ("Delivered ✓ ...");
|
||||
* locks the client (future intake queues instead of shipping) when the
|
||||
balance after the debit falls below the debit floor, or when the debit
|
||||
would push the balance negative (``overdue``) -- the delivered work
|
||||
stands, new work waits.
|
||||
|
||||
Everything is seam-injectable (``core`` = billing core, ``gitea`` = comment
|
||||
posting seam, ``intake`` = intake factory / lock seam) so tests are fully
|
||||
hermetic: FakeGitea + tmp-DB, no network, no real accounts.
|
||||
|
||||
The real GiteaClient/config/intake_factory modules land in sibling issues
|
||||
(#4/#5/#6); this module only depends on duck-typed seams so it does not
|
||||
need those files to exist yet.
|
||||
|
||||
Remote wiring: ``handle_merge_webhook(payload, provided_key)`` returns a
|
||||
``(result_dict, http_status)`` pair (401 without a valid X-API-Key, 400 for
|
||||
a malformed payload, 200 otherwise) -- the server.py webhook route
|
||||
(issue #6's file, not touched here) just mounts it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Optional
|
||||
|
||||
# --- defaults (overridable via env, mirror issue #6's config.py) ----------
|
||||
|
||||
DEFAULT_TIERS: dict[str, dict[str, Any]] = {
|
||||
"gold": {"rate_minor": 100}, # $1.00 / issue
|
||||
"standard": {"rate_minor": 25}, # $0.25 / issue
|
||||
"free": {"rate_minor": 0}, # never debited
|
||||
}
|
||||
DEFAULT_RATE_MINOR = 100 # SAAS_RATE_MINOR (100 = $1.00 / issue)
|
||||
DEFAULT_FLOOR_MINOR = 500 # SAAS_DEBIT_FLOOR_MINOR ($5.00; matches issue #4)
|
||||
|
||||
KEY_PREFIX = "deliver"
|
||||
|
||||
# Error codes (structured, mirror billing-core conventions).
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
BAD_PAYLOAD = "BAD_PAYLOAD"
|
||||
UNKNOWN_CLIENT = "UNKNOWN_CLIENT"
|
||||
INTERNAL = "INTERNAL"
|
||||
|
||||
|
||||
def format_minor(amount_minor: Optional[int]) -> str:
|
||||
"""Render an integer minor-unit amount as ``$X.YY`` (100 -> "$1.00")."""
|
||||
if amount_minor is None:
|
||||
amount = 0
|
||||
else:
|
||||
try:
|
||||
amount = max(int(amount_minor), 0)
|
||||
except (TypeError, ValueError):
|
||||
amount = 0
|
||||
return f"${amount // 100}.{amount % 100:02d}"
|
||||
|
||||
|
||||
def _int_env(env: Mapping[str, str], key: str, default: int) -> int:
|
||||
try:
|
||||
return int(env.get(key, ""))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _parse_tiers(env: Mapping[str, str]) -> dict[str, dict[str, Any]]:
|
||||
"""Tiers from ``SAAS_TIERS_JSON`` merged over defaults (config, not code)."""
|
||||
tiers = {tier: dict(cfg) for tier, cfg in DEFAULT_TIERS.items()}
|
||||
raw = env.get("SAAS_TIERS_JSON")
|
||||
if raw:
|
||||
try:
|
||||
overrides = json.loads(raw)
|
||||
except ValueError:
|
||||
overrides = None
|
||||
if isinstance(overrides, dict):
|
||||
for tier, cfg in overrides.items():
|
||||
if isinstance(cfg, dict):
|
||||
tiers[tier] = {**tiers.get(tier, {}), **cfg}
|
||||
return tiers
|
||||
|
||||
|
||||
def default_config(env: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
|
||||
"""Delivery config. ``env`` is an explicit mapping in tests; production
|
||||
reads the process environment.
|
||||
"""
|
||||
source: Mapping[str, str] = os.environ if env is None else env
|
||||
return {
|
||||
"tiers": _parse_tiers(source),
|
||||
"rate_minor": _int_env(source, "SAAS_RATE_MINOR", DEFAULT_RATE_MINOR),
|
||||
"floor_minor": _int_env(source, "SAAS_DEBIT_FLOOR_MINOR", DEFAULT_FLOOR_MINOR),
|
||||
"api_key": source.get("SAAS_API_KEY"),
|
||||
}
|
||||
|
||||
|
||||
def _deliver_key(repo: str, pr_number: int, head_sha: str) -> str:
|
||||
return f"{KEY_PREFIX}:{repo}:{pr_number}:{head_sha}"
|
||||
|
||||
|
||||
class Delivery:
|
||||
"""Delivery-receipt flow, wired to injectable seams."""
|
||||
|
||||
def __init__(self, core, gitea=None, intake=None,
|
||||
config: Optional[dict[str, Any]] = None):
|
||||
self.core = core
|
||||
self.gitea = gitea # seam: post_comment(repo, number, body)
|
||||
self.intake = intake # seam: lock(client_id)
|
||||
self.config = dict(config) if config is not None else default_config()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auth (mounted by the server.py webhook route)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def authorized(self, provided_key: Optional[str]) -> bool:
|
||||
"""Fail-closed, constant-time: a request is authorized only when
|
||||
the configured ``SAAS_API_KEY`` is set and matches the provided
|
||||
key exactly.
|
||||
|
||||
The comparison uses :func:`hmac.compare_digest` (no timing side
|
||||
channel on the shared secret's prefix). ``compare_digest`` raises
|
||||
``TypeError`` on non-str input, so the ``isinstance`` guard runs
|
||||
first — unset/empty config and non-str/empty keys all return
|
||||
``False``.
|
||||
"""
|
||||
expected = self.config.get("api_key")
|
||||
if not expected:
|
||||
return False
|
||||
if not isinstance(provided_key, str) or not provided_key:
|
||||
return False
|
||||
return hmac.compare_digest(provided_key, expected)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# The debit primitive
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def debit_for_merge(self, client_id: int, repo: str, pr_number: int,
|
||||
head_sha: str, rate_minor: int) -> dict[str, Any]:
|
||||
"""Idempotent debit for a merged PR. Returns ``{ok, balance, ...}``.
|
||||
|
||||
Idempotency key = ``deliver:<repo>:<pr>:<head_sha>`` -- replaying the
|
||||
same key (duplicate webhook delivery) is a no-op in the billing core
|
||||
and returns ``replayed: True`` with the existing balance.
|
||||
"""
|
||||
key = _deliver_key(repo, pr_number, head_sha)
|
||||
ok, details = self.core.debit(
|
||||
client_id, rate_minor, key,
|
||||
ref_type="pr", ref_id=f"{repo}#{pr_number}")
|
||||
result: dict[str, Any] = {"ok": ok, "balance": details.get("balance"),
|
||||
"idempotency_key": key}
|
||||
result.update(details)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Receipt comment + lock
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _receipt_comment(debit: dict[str, Any], balance: Optional[int],
|
||||
overdue: bool) -> str:
|
||||
rate = debit.get("amount_minor")
|
||||
if overdue:
|
||||
return ("Delivered \u2713 \u2014 balance after: "
|
||||
f"{format_minor(balance)} \u2014 **overdue** "
|
||||
f"(debit {format_minor(rate)} rejected; work delivered, "
|
||||
"client locked, new work queues).")
|
||||
if not rate:
|
||||
return "Delivered \u2713 \u2014 no charge (tier rate 0)."
|
||||
return f"Delivered \u2713 \u2014 balance after: {format_minor(balance)}"
|
||||
|
||||
def _post_receipt(self, repo: str, pr_number: int, comment: str) -> None:
|
||||
if self.gitea is not None:
|
||||
self.gitea.post_comment(repo, pr_number, comment)
|
||||
|
||||
def _lock_client(self, client_id: int) -> bool:
|
||||
"""Lock the client so intake queues future submits. Returns whether
|
||||
the lock was applied (False when no intake seam is wired yet)."""
|
||||
if self.intake is None:
|
||||
return False
|
||||
try:
|
||||
self.intake.lock(client_id)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Webhook flow
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _tier_rate(self, client_id: int) -> tuple[Optional[int], Optional[dict]]:
|
||||
ok, details = self.core.get_client(client_id)
|
||||
if not ok:
|
||||
return None, details.get("error")
|
||||
tier = details.get("tier") or "free" # flat record since #16 (was nested "client")
|
||||
tiers = self.config.get("tiers") or {}
|
||||
rate = (tiers.get(tier) or {}).get("rate_minor")
|
||||
if rate is None:
|
||||
rate = self.config.get("rate_minor", DEFAULT_RATE_MINOR)
|
||||
if not isinstance(rate, int) or isinstance(rate, bool):
|
||||
return None, {"code": INTERNAL, "message": f"invalid rate_minor for tier {tier!r}"}
|
||||
return rate, None
|
||||
|
||||
def on_merge_webhook(self, client_id: int, repo: str, pr_number: int,
|
||||
head_sha: str) -> dict[str, Any]:
|
||||
"""Run the full delivery flow for one merge event.
|
||||
|
||||
Returns a structured dict (never raises). ``ok`` reflects that the
|
||||
webhook was handled -- a rejected debit does NOT block the merge; it
|
||||
is recorded as ``overdue`` and the client is locked instead. A
|
||||
duplicate webhook (same repo/pr/head_sha) is a no-op and posts no
|
||||
second comment.
|
||||
"""
|
||||
rate, err = self._tier_rate(client_id)
|
||||
if err is not None:
|
||||
return {"ok": False, "error": err, "comment_posted": False,
|
||||
"locked": False}
|
||||
assert rate is not None # err is None => rate resolved
|
||||
|
||||
if rate <= 0:
|
||||
# Free / zero-rate tier: receipt only, no debit, no lock.
|
||||
_, bdet = self.core.get_balance(client_id)
|
||||
balance = bdet.get("balance")
|
||||
comment = self._receipt_comment({"amount_minor": 0}, balance, False)
|
||||
self._post_receipt(repo, pr_number, comment)
|
||||
return {"ok": True, "rate_minor": 0, "balance": balance,
|
||||
"overdue": False, "replayed": False, "locked": False,
|
||||
"comment": comment}
|
||||
|
||||
debit = self.debit_for_merge(client_id, repo, pr_number, head_sha, rate)
|
||||
|
||||
if debit.get("replayed"):
|
||||
# Duplicate webhook delivery of the same merge: already debited
|
||||
# and receipted -- no second debit, no second comment.
|
||||
return {"ok": True, "replayed": True, "balance": debit.get("balance"),
|
||||
"rate_minor": rate, "overdue": False, "locked": False,
|
||||
"comment": None, "debit": debit}
|
||||
|
||||
balance = debit.get("balance")
|
||||
overdue = not debit.get("ok")
|
||||
floor = int(self.config.get("floor_minor", DEFAULT_FLOOR_MINOR))
|
||||
below_floor = (not overdue) and balance is not None and balance < floor
|
||||
|
||||
locked = False
|
||||
if overdue or below_floor:
|
||||
locked = self._lock_client(client_id)
|
||||
|
||||
comment = self._receipt_comment(debit, balance, overdue)
|
||||
self._post_receipt(repo, pr_number, comment)
|
||||
|
||||
return {"ok": True, "replayed": False, "balance": balance,
|
||||
"rate_minor": rate, "overdue": overdue,
|
||||
"below_floor": below_floor, "locked": locked,
|
||||
"comment": comment, "debit": debit}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Server.py mountable handler (payload parsing + auth)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def handle_merge_webhook(self, payload: Any,
|
||||
provided_key: Optional[str] = None
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
"""Tolerant webhook entry point returning ``(result, http_status)``.
|
||||
|
||||
Payload shape (proxy mapper / manual POST):
|
||||
``{repo, pr, head_sha, client_id}``
|
||||
Also tolerant of Gitea's own merged ``pull_request`` event where
|
||||
``repository.full_name`` + ``pull_request.number`` /
|
||||
``pull_request.head.sha`` are present and ``client_id`` comes from
|
||||
the mapper.
|
||||
|
||||
Status codes: 401 without a valid key, 400 for malformed/missing
|
||||
fields, 200 otherwise.
|
||||
"""
|
||||
if not self.authorized(provided_key):
|
||||
return ({"ok": False, "error": {"code": UNAUTHORIZED,
|
||||
"message": "bad or missing X-API-Key"}}), 401
|
||||
if not isinstance(payload, dict):
|
||||
return ({"ok": False, "error": {"code": BAD_PAYLOAD,
|
||||
"message": "expected JSON object"}}), 400
|
||||
|
||||
repo = payload.get("repo")
|
||||
if not repo and isinstance(payload.get("repository"), dict):
|
||||
repo = payload["repository"].get("full_name")
|
||||
pr = payload.get("pr")
|
||||
if pr is None and isinstance(payload.get("pull_request"), dict):
|
||||
pr = payload["pull_request"].get("number")
|
||||
head_sha = payload.get("head_sha")
|
||||
if not head_sha and isinstance(payload.get("pull_request"), dict):
|
||||
head_sha = (payload["pull_request"].get("head") or {}).get("sha")
|
||||
client_id = payload.get("client_id")
|
||||
|
||||
if not repo or pr is None or not head_sha or client_id is None:
|
||||
return ({"ok": False, "error": {"code": BAD_PAYLOAD,
|
||||
"message": "payload must include "
|
||||
"repo, pr, head_sha, "
|
||||
"client_id"}}), 400
|
||||
|
||||
try:
|
||||
result = self.on_merge_webhook(int(client_id), str(repo),
|
||||
int(pr), str(head_sha))
|
||||
except (TypeError, ValueError) as e:
|
||||
return ({"ok": False, "error": {"code": BAD_PAYLOAD,
|
||||
"message": f"invalid fields: {e}"}}), 400
|
||||
return result, 200
|
||||
323
tests/test_delivery.py
Normal file
323
tests/test_delivery.py
Normal file
@ -0,0 +1,323 @@
|
||||
"""Hermetic tests for delivery receipts (issue #7).
|
||||
|
||||
No network, no real Gitea, no real accounts: FakeGitea + tmp-SQLite-DB only.
|
||||
Run with: python -m pytest tests/test_delivery.py -q
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# The package is a 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.delivery import ( # noqa: E402
|
||||
Delivery,
|
||||
default_config,
|
||||
format_minor,
|
||||
)
|
||||
|
||||
REPO = "client-org/widgets"
|
||||
PR = 42
|
||||
HEAD = "abc123deadbeef"
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
"""Records comment posts; hermetic stand-in for the real GiteaClient."""
|
||||
|
||||
def __init__(self):
|
||||
self.comments = []
|
||||
|
||||
def post_comment(self, repo, number, body):
|
||||
self.comments.append({"repo": repo, "number": number, "body": body})
|
||||
|
||||
|
||||
class FakeIntake:
|
||||
"""Records lock calls; hermetic stand-in for issue #4's IntakeFactory."""
|
||||
|
||||
def __init__(self):
|
||||
self.locked = []
|
||||
|
||||
def lock(self, client_id):
|
||||
self.locked.append(client_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def core(tmp_path):
|
||||
core = BillingCore(tmp_path / "billing.db")
|
||||
# seed a funded client; helpers credit below
|
||||
return core
|
||||
|
||||
|
||||
def make_client(core, name="alice", tier="gold"):
|
||||
ok, details = core.create_client(name, tier=tier)
|
||||
assert ok, details
|
||||
return details["id"] # record key is "id" since #16 (was "client_id")
|
||||
|
||||
|
||||
def credit(core, client_id, amount_minor):
|
||||
ok, details = core.credit(client_id, amount_minor, f"seed:{client_id}:{amount_minor}")
|
||||
assert ok, details
|
||||
return details["balance"]
|
||||
|
||||
|
||||
def make_delivery(core, gitea=None, intake=None, config=None):
|
||||
return Delivery(core, gitea=gitea, intake=intake, config=config)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Merge webhook -> one debit at tier rate, balance reflected, receipt posted
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_merge_webhook_one_debit_at_tier_rate_with_receipt(core):
|
||||
gitea = FakeGitea()
|
||||
intake = FakeIntake()
|
||||
client_id = make_client(core, tier="gold") # gold rate = 100 ($1.00)
|
||||
credit(core, client_id, 10000) # $100.00
|
||||
|
||||
d = make_delivery(core, gitea=gitea, intake=intake)
|
||||
result = d.on_merge_webhook(client_id, REPO, PR, HEAD)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["overdue"] is False
|
||||
assert result["rate_minor"] == 100
|
||||
assert result["balance"] == 9900 # 10000 - 100
|
||||
assert result["replayed"] is False
|
||||
# exactly one receipt comment, mentioning balance + delivered check
|
||||
assert len(gitea.comments) == 1
|
||||
assert "Delivered \u2713" in gitea.comments[0]["body"]
|
||||
assert "$99.00" in gitea.comments[0]["body"]
|
||||
# debit recorded in ledger with the delivery idempotency key + pr ref
|
||||
ok, ledger = core.list_ledger(client_id)
|
||||
assert ok
|
||||
debits = [e for e in ledger["entries"] if e["kind"] == "debit"]
|
||||
assert len(debits) == 1
|
||||
assert debits[0]["idempotency_key"] == f"deliver:{REPO}:{PR}:{HEAD}"
|
||||
assert debits[0]["ref_type"] == "pr"
|
||||
assert debits[0]["ref_id"] == f"{REPO}#{PR}"
|
||||
# balance above floor -> client NOT locked
|
||||
assert intake.locked == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Duplicate webhook -> no second debit, no second comment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_duplicate_webhook_no_second_debit_no_second_comment(core):
|
||||
gitea = FakeGitea()
|
||||
intake = FakeIntake()
|
||||
client_id = make_client(core, tier="gold")
|
||||
credit(core, client_id, 10000)
|
||||
|
||||
d = make_delivery(core, gitea=gitea, intake=intake)
|
||||
first = d.on_merge_webhook(client_id, REPO, PR, HEAD)
|
||||
second = d.on_merge_webhook(client_id, REPO, PR, HEAD) # duplicate delivery
|
||||
|
||||
assert first["ok"] is True
|
||||
assert second["ok"] is True
|
||||
assert second["replayed"] is True
|
||||
assert second["balance"] == first["balance"] == 9900
|
||||
# exactly one debit row, one comment
|
||||
ok, ledger = core.list_ledger(client_id)
|
||||
assert ok
|
||||
debits = [e for e in ledger["entries"] if e["kind"] == "debit"]
|
||||
assert len(debits) == 1
|
||||
assert len(gitea.comments) == 1
|
||||
assert intake.locked == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Balance below floor after debit -> client locked (future intake queues)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_balance_below_floor_locks_client(core):
|
||||
gitea = FakeGitea()
|
||||
intake = FakeIntake()
|
||||
client_id = make_client(core, tier="gold")
|
||||
credit(core, client_id, 550) # $5.50; after $1.00 debit -> $4.50 < $5.00 floor
|
||||
|
||||
d = make_delivery(core, gitea=gitea, intake=intake)
|
||||
result = d.on_merge_webhook(client_id, REPO, PR, HEAD)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["below_floor"] is True
|
||||
assert result["locked"] is True
|
||||
assert intake.locked == [client_id]
|
||||
assert result["balance"] == 450
|
||||
assert len(gitea.comments) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Debit would go negative -> merge NOT blocked, overdue noted, client locked
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_negative_debit_does_not_block_merge_overdue_and_lock(core):
|
||||
gitea = FakeGitea()
|
||||
intake = FakeIntake()
|
||||
client_id = make_client(core, tier="gold")
|
||||
credit(core, client_id, 50) # $0.50 < $1.00 debit -> would go negative
|
||||
|
||||
d = make_delivery(core, gitea=gitea, intake=intake)
|
||||
result = d.on_merge_webhook(client_id, REPO, PR, HEAD)
|
||||
|
||||
# merge not blocked: webhook reports handled ok
|
||||
assert result["ok"] is True
|
||||
assert result["overdue"] is True
|
||||
assert result["debit"]["ok"] is False # debit itself rejected
|
||||
assert result["balance"] is None
|
||||
assert intake.locked == [client_id] # client locked for new work
|
||||
# receipt comment records overdue
|
||||
assert len(gitea.comments) == 1
|
||||
assert "overdue" in gitea.comments[0]["body"]
|
||||
# no debit row was written (fail-closed core wrote nothing)
|
||||
ok, ledger = core.list_ledger(client_id)
|
||||
assert ok
|
||||
debits = [e for e in ledger["entries"] if e["kind"] == "debit"]
|
||||
assert debits == []
|
||||
# balance unchanged
|
||||
_, bal = core.get_balance(client_id)
|
||||
assert bal["balance"] == 50
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook without auth -> 401
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_webhook_without_auth_401(core):
|
||||
client_id = make_client(core)
|
||||
credit(core, client_id, 10000)
|
||||
d = make_delivery(core, config=default_config({"SAAS_API_KEY": "sekret"}))
|
||||
payload = {"repo": REPO, "pr": PR, "head_sha": HEAD, "client_id": client_id}
|
||||
|
||||
result, status = d.handle_merge_webhook(payload, provided_key=None)
|
||||
assert status == 401
|
||||
assert result["error"]["code"] == "UNAUTHORIZED"
|
||||
|
||||
result, status = d.handle_merge_webhook(payload, provided_key="wrong")
|
||||
assert status == 401
|
||||
|
||||
|
||||
def test_webhook_with_auth_ok(core):
|
||||
gitea = FakeGitea()
|
||||
client_id = make_client(core, tier="gold")
|
||||
credit(core, client_id, 10000)
|
||||
d = make_delivery(core, gitea=gitea,
|
||||
config=default_config({"SAAS_API_KEY": "sekret"}))
|
||||
payload = {"repo": REPO, "pr": PR, "head_sha": HEAD, "client_id": client_id}
|
||||
|
||||
result, status = d.handle_merge_webhook(payload, provided_key="sekret")
|
||||
assert status == 200
|
||||
assert result["ok"] is True
|
||||
assert result["balance"] == 9900
|
||||
assert len(gitea.comments) == 1
|
||||
|
||||
|
||||
def test_webhook_bad_payload_400(core):
|
||||
d = make_delivery(core, config=default_config({"SAAS_API_KEY": "sekret"}))
|
||||
# missing client_id
|
||||
result, status = d.handle_merge_webhook(
|
||||
{"repo": REPO, "pr": PR, "head_sha": HEAD}, provided_key="sekret")
|
||||
assert status == 400
|
||||
assert result["error"]["code"] == "BAD_PAYLOAD"
|
||||
# non-dict payload
|
||||
result, status = d.handle_merge_webhook(["nope"], provided_key="sekret")
|
||||
assert status == 400
|
||||
|
||||
|
||||
def test_webhook_tolerant_gitea_shape(core):
|
||||
"""Gitea's own pull_request event shape is tolerated (full_name+number)."""
|
||||
gitea = FakeGitea()
|
||||
client_id = make_client(core, tier="gold")
|
||||
credit(core, client_id, 10000)
|
||||
d = make_delivery(core, gitea=gitea,
|
||||
config=default_config({"SAAS_API_KEY": "sekret"}))
|
||||
payload = {
|
||||
"action": "closed",
|
||||
"repository": {"full_name": REPO},
|
||||
"pull_request": {"number": PR, "merged": True,
|
||||
"head": {"sha": HEAD}},
|
||||
"client_id": client_id,
|
||||
}
|
||||
result, status = d.handle_merge_webhook(payload, provided_key="sekret")
|
||||
assert status == 200
|
||||
assert result["ok"] is True
|
||||
assert result["balance"] == 9900
|
||||
assert len(gitea.comments) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier rate from config (not hardcoded) + SAAS_TIERS_JSON env override
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_rate_from_tier_config_override(core):
|
||||
gitea = FakeGitea()
|
||||
client_id = make_client(core, tier="standard") # standard default 25
|
||||
credit(core, client_id, 10000)
|
||||
config = default_config({"SAAS_TIERS_JSON": '{"standard": {"rate_minor": 77}}'})
|
||||
d = make_delivery(core, gitea=gitea, config=config)
|
||||
result = d.on_merge_webhook(client_id, REPO, PR, HEAD)
|
||||
assert result["rate_minor"] == 77
|
||||
assert result["balance"] == 10000 - 77
|
||||
|
||||
|
||||
def test_zero_rate_tier_no_debit(core):
|
||||
gitea = FakeGitea()
|
||||
intake = FakeIntake()
|
||||
client_id = make_client(core, tier="free") # free rate = 0
|
||||
credit(core, client_id, 10000)
|
||||
d = make_delivery(core, gitea=gitea, intake=intake)
|
||||
result = d.on_merge_webhook(client_id, REPO, PR, HEAD)
|
||||
assert result["ok"] is True
|
||||
assert result["rate_minor"] == 0
|
||||
assert result["balance"] == 10000 # no debit
|
||||
assert intake.locked == [] # never locked on rate-0
|
||||
assert "no charge" in gitea.comments[0]["body"]
|
||||
ok, ledger = core.list_ledger(client_id)
|
||||
assert ok
|
||||
assert [e for e in ledger["entries"] if e["kind"] == "debit"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_minor helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_format_minor():
|
||||
assert format_minor(100) == "$1.00"
|
||||
assert format_minor(0) == "$0.00"
|
||||
assert format_minor(450) == "$4.50"
|
||||
assert format_minor(None) == "$0.00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# authorized(): fail-closed, constant-time API-key check (hmac.compare_digest)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_authorized_matching_key():
|
||||
d = make_delivery(core=None, config={"api_key": "sekret"})
|
||||
assert d.authorized("sekret") is True
|
||||
|
||||
|
||||
def test_authorized_wrong_key():
|
||||
d = make_delivery(core=None, config={"api_key": "sekret"})
|
||||
assert d.authorized("sekret-wrong") is False
|
||||
assert d.authorized("") is False
|
||||
|
||||
|
||||
def test_authorized_fail_closed_without_configured_key():
|
||||
d = make_delivery(core=None, config=default_config({})) # no SAAS_API_KEY
|
||||
assert d.authorized("anything") is False
|
||||
|
||||
|
||||
def test_authorized_none_provided_key_is_false_not_typeerror():
|
||||
d = make_delivery(core=None, config={"api_key": "sekret"})
|
||||
assert d.authorized(None) is False
|
||||
|
||||
|
||||
def test_authorized_non_str_provided_key_is_false_not_typeerror():
|
||||
# hmac.compare_digest raises TypeError on bytes-vs-str; the isinstance
|
||||
# guard must reject non-str input before the comparison runs.
|
||||
d = make_delivery(core=None, config={"api_key": "sekret"})
|
||||
assert d.authorized(b"sekret") is False # type: ignore[arg-type]
|
||||
Loading…
Reference in New Issue
Block a user