gitea-saas-core/tests/test_delivery.py

324 lines
12 KiB
Python

"""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]