feat(v2): CRUD + per-direction fee cap validation (#38 2/5)
Wires the new directional fee fields through the write path and adds the 15%-per-direction cap guard at the API boundary. CRUD: - create_machine INSERT includes operator_cash_in_fee_fraction + operator_cash_out_fee_fraction (Pydantic default 0 covers existing callers). - update_machine + update_super_config already use generic update_data dict, so the new fields flow through without per-call changes. API boundary (views_api.py): - _assert_machine_fee_cap_safe(operator_in, operator_out) — pairs candidates against current super-config, rejects if (super_X + operator_X) > 0.15 for either direction. Called from api_create_machine + api_update_machine (with partial-PATCH semantics: unset fields keep the machine's current value). - _assert_super_config_cap_safe(new_super_in, new_super_out) — fetches every active machine; rejects with offending-machine name in the 400 detail if any (effective_super + operator) > cap. Called from api_update_super_config. Cap rounding: float arithmetic rounds (super + operator) to 4 decimals (DECIMAL(10,4) precision) before comparing, so the IEEE 754 surprise 0.10 + 0.05 = 0.15000000000000002 doesn't trip the cap. Tests (13 cases, all green): both directions hit the cap, exact-cap acceptance, no-super-config degenerate path, partial PATCH on super-config, offending-machine name in error detail, empty-fleet vacuous safety. Refs: aiolabs/satmachineadmin#38 (Layer 1), coord-log §2026-06-01T07:22Z (cap lock at 15% per direction, defense in depth). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d87d0db324
commit
4cd0041923
3 changed files with 342 additions and 2 deletions
208
tests/test_fee_cap_validation.py
Normal file
208
tests/test_fee_cap_validation.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""
|
||||
Tests for `views_api._assert_machine_fee_cap_safe` and
|
||||
`_assert_super_config_cap_safe` (aiolabs/satmachineadmin#38, Layer 1).
|
||||
|
||||
Per-direction cap is locked at 15% (super + operator) per coord-log
|
||||
§2026-06-01T07:22Z. Both helpers enforce the same cap from the
|
||||
opposite direction:
|
||||
|
||||
- machine_fee_cap_safe runs at machine create/update; pairs candidate
|
||||
operator fractions against the current super-config
|
||||
- super_config_cap_safe runs at super-config update; pairs candidate
|
||||
super fractions against every active machine's operator fractions
|
||||
and names the first offender so the super-admin can fix the
|
||||
triggering machine
|
||||
|
||||
Tests monkeypatch the CRUD lookups directly — same shape as
|
||||
test_collision_guard.py — so the validators are unit-testable without
|
||||
a live LNbits DB.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from .. import views_api
|
||||
from ..views_api import (
|
||||
_assert_machine_fee_cap_safe,
|
||||
_assert_super_config_cap_safe,
|
||||
)
|
||||
|
||||
|
||||
def _super_config(in_frac: float = 0.0, out_frac: float = 0.0):
|
||||
"""Duck-typed super-config row carrying just the two directional fields
|
||||
the cap helpers read."""
|
||||
return SimpleNamespace(
|
||||
super_cash_in_fee_fraction=in_frac,
|
||||
super_cash_out_fee_fraction=out_frac,
|
||||
)
|
||||
|
||||
|
||||
def _machine(
|
||||
machine_id: str,
|
||||
op_in: float,
|
||||
op_out: float,
|
||||
name: str | None = None,
|
||||
npub: str = "a" * 64,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
id=machine_id,
|
||||
operator_cash_in_fee_fraction=op_in,
|
||||
operator_cash_out_fee_fraction=op_out,
|
||||
name=name,
|
||||
machine_npub=npub,
|
||||
)
|
||||
|
||||
|
||||
def _patch_super(monkeypatch, value):
|
||||
async def fake_get():
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(views_api, "get_super_config", fake_get)
|
||||
|
||||
|
||||
def _patch_machines(monkeypatch, machines: list):
|
||||
async def fake_list():
|
||||
return machines
|
||||
|
||||
monkeypatch.setattr(views_api, "list_all_active_machines", fake_list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _assert_machine_fee_cap_safe — candidate operator fractions vs current super
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMachineFeeCapSafe:
|
||||
def test_cash_in_cap_exceeded_raises(self, monkeypatch):
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.05))
|
||||
# 0.10 + 0.06 = 0.16 > 0.15 → reject
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.06, 0.05))
|
||||
assert exc.value.status_code == 400
|
||||
assert "cash-in fee cap exceeded" in exc.value.detail
|
||||
|
||||
def test_cash_out_cap_exceeded_raises(self, monkeypatch):
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.05, out_frac=0.10))
|
||||
# 0.10 + 0.06 = 0.16 > 0.15 → reject
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.05, 0.06))
|
||||
assert exc.value.status_code == 400
|
||||
assert "cash-out fee cap exceeded" in exc.value.detail
|
||||
|
||||
def test_at_exact_cap_passes(self, monkeypatch):
|
||||
"""The cap check is `>`, not `>=` — operators may set exactly
|
||||
15% on either direction without rejection."""
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.10))
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.05, 0.05))
|
||||
|
||||
def test_no_super_config_treats_super_as_zero(self, monkeypatch):
|
||||
"""Uninitialised instance (super_config = None) → only operator
|
||||
counts. Cap then degenerates to a pure operator-fee check."""
|
||||
_patch_super(monkeypatch, None)
|
||||
# 0.14 alone is under cap → pass
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.14, 0.14))
|
||||
# 0.16 alone exceeds cap → reject
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.16, 0.05))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_well_under_cap_passes_silently(self, monkeypatch):
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03))
|
||||
# Should not raise.
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.0333, 0.0777))
|
||||
|
||||
def test_zero_operator_under_zero_super_passes(self, monkeypatch):
|
||||
"""Free-charge ATM corner case — operator deliberately sets 0
|
||||
on both directions, super is 0 on both. Cap of 0 ≤ 0.15."""
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.0, out_frac=0.0))
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.0, 0.0))
|
||||
|
||||
def test_error_detail_includes_cap_value(self, monkeypatch):
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.0))
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.10, 0.0))
|
||||
# 0.10 + 0.10 = 0.20 > 0.15
|
||||
assert "0.15" in exc.value.detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _assert_super_config_cap_safe — candidate super fractions vs all machines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSuperConfigCapSafe:
|
||||
def test_offending_machine_raises_and_is_named(self, monkeypatch):
|
||||
"""When a super-fee bump pushes one machine over the cap, the
|
||||
rejection names that machine so the super-admin knows which
|
||||
operator's per-machine config blocks the change."""
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03))
|
||||
_patch_machines(
|
||||
monkeypatch,
|
||||
[
|
||||
_machine("m1", op_in=0.01, op_out=0.02, name="Cafe A"),
|
||||
_machine("m2", op_in=0.10, op_out=0.02, name="Greedy ATM"),
|
||||
],
|
||||
)
|
||||
# New super_in = 0.06. m2 has op_in 0.10 → 0.16 > cap.
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_super_config_cap_safe(0.06, None))
|
||||
assert exc.value.status_code == 400
|
||||
assert "Greedy ATM" in exc.value.detail or "m2" in exc.value.detail
|
||||
|
||||
def test_all_machines_under_cap_passes(self, monkeypatch):
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03))
|
||||
_patch_machines(
|
||||
monkeypatch,
|
||||
[
|
||||
_machine("m1", op_in=0.05, op_out=0.05, name="Cafe A"),
|
||||
_machine("m2", op_in=0.03, op_out=0.03, name="Cafe B"),
|
||||
],
|
||||
)
|
||||
# Bump super to 0.08/0.08 → max total = 0.13 + 0.13 = both under cap.
|
||||
asyncio.run(_assert_super_config_cap_safe(0.08, 0.08))
|
||||
|
||||
def test_none_direction_pulls_current_value(self, monkeypatch):
|
||||
"""Caller passes new_super_in=None → check uses current super_in
|
||||
value. Confirms partial-update semantics — caller can change
|
||||
cash-out alone without retransmitting cash-in."""
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.03))
|
||||
_patch_machines(monkeypatch, [_machine("m1", op_in=0.06, op_out=0.0)])
|
||||
# Skipping in (None) but op_in=0.06 + current super_in=0.10 = 0.16 > cap.
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_super_config_cap_safe(None, 0.05))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_no_machines_passes(self, monkeypatch):
|
||||
"""Cap check across an empty fleet is vacuously safe."""
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.10))
|
||||
_patch_machines(monkeypatch, [])
|
||||
asyncio.run(_assert_super_config_cap_safe(0.12, 0.12))
|
||||
|
||||
def test_no_super_config_with_machines_uses_zero(self, monkeypatch):
|
||||
"""Uninitialised super + new fractions → cap check still runs
|
||||
against the candidate new values + each machine's operator
|
||||
fractions."""
|
||||
_patch_super(monkeypatch, None)
|
||||
_patch_machines(
|
||||
monkeypatch,
|
||||
[_machine("m1", op_in=0.10, op_out=0.0, name="Cafe A")],
|
||||
)
|
||||
# 0.06 + 0.10 = 0.16 > cap.
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_super_config_cap_safe(0.06, 0.0))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_uses_machine_id_when_name_missing(self, monkeypatch):
|
||||
"""Machines without a `name` set fall back to the id (or npub
|
||||
prefix) for the error message — operator-actionable in either
|
||||
case."""
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03))
|
||||
_patch_machines(
|
||||
monkeypatch,
|
||||
[_machine("unnamed-machine-id", op_in=0.10, op_out=0.0, name=None)],
|
||||
)
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_super_config_cap_safe(0.06, None))
|
||||
assert "unnamed-machine-id" in exc.value.detail
|
||||
Loading…
Add table
Add a link
Reference in a new issue