248 lines
9.4 KiB
Python
248 lines
9.4 KiB
Python
"""Property + invariant tests for m2_ledger (contracts/ledger-api.md §Invariants).
|
|
|
|
1. No endpoint UPDATEs/DELETEs tx rows -> append-only by construction,
|
|
exercised indirectly by every test.
|
|
2. balance == fold(tx log) for every operator -> test_balance_matches_independent_fold
|
|
3. Every grant has a non-null paying tx -> test_every_license_grant_has_paying_tx
|
|
4. /tx/install is all-or-nothing and idempotent by ref -> test_insufficient_funds_leaves_zero_new_rows
|
|
test_duplicate_ref_leaves_zero_new_rows
|
|
test_api_install_402_leaves_tx_log_unchanged
|
|
Plus: refund exactly compensates (test_refund_restores_pre_install_balances) and no balance
|
|
except mint goes negative (checked as a stateful invariant below).
|
|
"""
|
|
|
|
from hypothesis import HealthCheck, assume, settings
|
|
from hypothesis import strategies as st
|
|
from hypothesis.stateful import RuleBasedStateMachine, invariant, rule
|
|
|
|
from m2_ledger import models
|
|
|
|
OPERATORS = ["op_alice", "op_bob", "op_carol"]
|
|
ALL_ACCOUNTS = OPERATORS + ["platform"]
|
|
|
|
|
|
def _fold_balance(conn, operator_id: str) -> int:
|
|
"""Independent re-implementation of models.balance: fold the raw tx log in
|
|
Python rather than SQL, so this actually cross-checks the SQL aggregation."""
|
|
bal = 0
|
|
for row in conn.execute("SELECT from_id, to_id, amount FROM tx").fetchall():
|
|
if row["to_id"] == operator_id:
|
|
bal += row["amount"]
|
|
if row["from_id"] == operator_id:
|
|
bal -= row["amount"]
|
|
return bal
|
|
|
|
|
|
def _known_accounts(conn) -> set:
|
|
ids = set()
|
|
for row in conn.execute("SELECT from_id, to_id FROM tx").fetchall():
|
|
ids.add(row["from_id"])
|
|
ids.add(row["to_id"])
|
|
return ids
|
|
|
|
|
|
class LedgerMachine(RuleBasedStateMachine):
|
|
"""Arbitrary sequences of valid ops (grant, install, refund) against a fresh
|
|
in-memory ledger, checking fold-consistency and non-negativity after every op."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.conn = models.init_db(":memory:")
|
|
self.installed_refs = []
|
|
self._ref_counter = 0
|
|
|
|
def teardown(self):
|
|
self.conn.close()
|
|
|
|
@rule(
|
|
operator=st.sampled_from(OPERATORS),
|
|
amount=st.integers(min_value=1, max_value=1000),
|
|
)
|
|
def grant(self, operator, amount):
|
|
models.record_grant(self.conn, operator, amount)
|
|
|
|
@rule(
|
|
pair=st.permutations(OPERATORS).map(lambda p: (p[0], p[1])),
|
|
amount=st.integers(min_value=1, max_value=500),
|
|
pct=st.floats(min_value=0.0, max_value=1.0, allow_nan=False),
|
|
)
|
|
def install(self, pair, amount, pct):
|
|
buyer, seller = pair
|
|
# only attempt ops that are valid per the buyer's *actual* current balance,
|
|
# so this models "arbitrary sequences of valid ops", not failure paths.
|
|
if models.balance(self.conn, buyer) < amount:
|
|
return
|
|
# A platform_pct that rounds the cut or the seller's net to exactly 0
|
|
# trips a known source bug (see report: zero-amount tx row violates the
|
|
# `amount > 0` CHECK constraint with an unhandled IntegrityError) —
|
|
# excluded here since it's not part of this property, not a fix.
|
|
cut = int(amount * pct)
|
|
assume(0 < cut < amount)
|
|
self._ref_counter += 1
|
|
ref = f"ref-{self._ref_counter}"
|
|
models.record_install(self.conn, buyer, seller, amount, pct, ref)
|
|
self.installed_refs.append(ref)
|
|
|
|
@rule(data=st.data())
|
|
def refund(self, data):
|
|
if not self.installed_refs:
|
|
return
|
|
ref = data.draw(st.sampled_from(self.installed_refs))
|
|
try:
|
|
models.record_refund(self.conn, ref)
|
|
except models.AlreadyRefunded:
|
|
pass
|
|
else:
|
|
self.installed_refs.remove(ref)
|
|
|
|
@invariant()
|
|
def balances_match_independent_fold(self):
|
|
for account in _known_accounts(self.conn):
|
|
assert models.balance(self.conn, account) == _fold_balance(self.conn, account)
|
|
|
|
@invariant()
|
|
def no_non_mint_balance_goes_negative(self):
|
|
for account in _known_accounts(self.conn):
|
|
if account == "mint":
|
|
continue
|
|
assert models.balance(self.conn, account) >= 0
|
|
|
|
|
|
LedgerStateMachineTest = LedgerMachine.TestCase
|
|
LedgerStateMachineTest.settings = settings(
|
|
max_examples=50,
|
|
stateful_step_count=25,
|
|
suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large],
|
|
deadline=None,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Example-based invariant tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_every_license_grant_has_paying_tx(conn):
|
|
"""Invariant 3: install grants reference a real 'install' tx; starter grants
|
|
(money only, reason='grant') never create a license grant_ row at all."""
|
|
models.record_grant(conn, "op_alice", 100)
|
|
result = models.record_install(conn, "op_alice", "op_bob", 40, 0.10, "ref-install-1")
|
|
|
|
grant_row = result["grant"]
|
|
assert grant_row["tx_id"] is not None
|
|
|
|
paying_tx = conn.execute(
|
|
"SELECT * FROM tx WHERE id = ?", (grant_row["tx_id"],)
|
|
).fetchone()
|
|
assert paying_tx is not None
|
|
assert paying_tx["reason"] == "install"
|
|
assert paying_tx["from_id"] == "op_alice"
|
|
assert paying_tx["to_id"] == "op_bob"
|
|
|
|
# the starter grant produced a money tx but no row in grant_ (no license without payment)
|
|
grant_count = conn.execute("SELECT COUNT(*) AS n FROM grant_").fetchone()["n"]
|
|
assert grant_count == 1
|
|
|
|
|
|
def test_no_license_grant_without_install(conn):
|
|
"""A pure starter grant must never create a grant_ (license) row."""
|
|
models.record_grant(conn, "op_alice", 100)
|
|
grant_count = conn.execute("SELECT COUNT(*) AS n FROM grant_").fetchone()["n"]
|
|
assert grant_count == 0
|
|
|
|
|
|
def test_insufficient_funds_leaves_zero_new_rows(conn):
|
|
models.record_grant(conn, "op_alice", 10)
|
|
tx_count_before = conn.execute("SELECT COUNT(*) AS n FROM tx").fetchone()["n"]
|
|
grant_count_before = conn.execute("SELECT COUNT(*) AS n FROM grant_").fetchone()["n"]
|
|
|
|
try:
|
|
models.record_install(conn, "op_alice", "op_bob", 10_000, 0.10, "ref-too-much")
|
|
assert False, "expected InsufficientFunds"
|
|
except models.InsufficientFunds:
|
|
pass
|
|
|
|
tx_count_after = conn.execute("SELECT COUNT(*) AS n FROM tx").fetchone()["n"]
|
|
grant_count_after = conn.execute("SELECT COUNT(*) AS n FROM grant_").fetchone()["n"]
|
|
assert tx_count_after == tx_count_before
|
|
assert grant_count_after == grant_count_before
|
|
|
|
|
|
def test_duplicate_ref_leaves_zero_new_rows(conn):
|
|
models.record_grant(conn, "op_alice", 100)
|
|
models.record_install(conn, "op_alice", "op_bob", 40, 0.10, "ref-dup")
|
|
|
|
tx_count_before = conn.execute("SELECT COUNT(*) AS n FROM tx").fetchone()["n"]
|
|
grant_count_before = conn.execute("SELECT COUNT(*) AS n FROM grant_").fetchone()["n"]
|
|
|
|
try:
|
|
models.record_install(conn, "op_alice", "op_bob", 40, 0.10, "ref-dup")
|
|
assert False, "expected DuplicateRef"
|
|
except models.DuplicateRef:
|
|
pass
|
|
|
|
tx_count_after = conn.execute("SELECT COUNT(*) AS n FROM tx").fetchone()["n"]
|
|
grant_count_after = conn.execute("SELECT COUNT(*) AS n FROM grant_").fetchone()["n"]
|
|
assert tx_count_after == tx_count_before
|
|
assert grant_count_after == grant_count_before
|
|
|
|
|
|
def test_refund_restores_pre_install_balances(conn):
|
|
models.record_grant(conn, "op_alice", 100)
|
|
pre_install = {
|
|
op: models.balance(conn, op) for op in ("op_alice", "op_bob", "platform")
|
|
}
|
|
|
|
models.record_install(conn, "op_alice", "op_bob", 40, 0.10, "ref-refundable")
|
|
models.record_refund(conn, "ref-refundable")
|
|
|
|
post_refund = {
|
|
op: models.balance(conn, op) for op in ("op_alice", "op_bob", "platform")
|
|
}
|
|
assert post_refund == pre_install
|
|
|
|
|
|
def test_refund_twice_rejected_and_adds_no_rows(conn):
|
|
models.record_grant(conn, "op_alice", 100)
|
|
models.record_install(conn, "op_alice", "op_bob", 40, 0.10, "ref-once")
|
|
models.record_refund(conn, "ref-once")
|
|
|
|
tx_count_before = conn.execute("SELECT COUNT(*) AS n FROM tx").fetchone()["n"]
|
|
try:
|
|
models.record_refund(conn, "ref-once")
|
|
assert False, "expected AlreadyRefunded"
|
|
except models.AlreadyRefunded:
|
|
pass
|
|
tx_count_after = conn.execute("SELECT COUNT(*) AS n FROM tx").fetchone()["n"]
|
|
assert tx_count_after == tx_count_before
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API-level test (contract invariant 4, via TestClient)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_api_install_402_leaves_tx_log_unchanged(client, service_headers, admin_headers):
|
|
client.post(
|
|
"/grant/starter",
|
|
json={"operator_id": "op_alice", "amount": 10},
|
|
headers=admin_headers,
|
|
)
|
|
|
|
before = client.get("/tx", headers=service_headers).json()["transactions"]
|
|
|
|
resp = client.post(
|
|
"/tx/install",
|
|
json={
|
|
"buyer": "op_alice",
|
|
"seller": "op_bob",
|
|
"amount": 10_000,
|
|
"ref": "ref-api-too-much",
|
|
},
|
|
headers=service_headers,
|
|
)
|
|
assert resp.status_code == 402
|
|
assert resp.json()["error"] == "insufficient_funds"
|
|
|
|
after = client.get("/tx", headers=service_headers).json()["transactions"]
|
|
assert after == before
|