324 lines
12 KiB
Python
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]
|