diff --git a/tests/test_cassette_configs.py b/tests/test_cassette_configs.py index f9d9a4a..8526951 100644 --- a/tests/test_cassette_configs.py +++ b/tests/test_cassette_configs.py @@ -1,10 +1,10 @@ """ -Tests for the v1 cassette-config layer (aiolabs/satmachineadmin#29). +Tests for the v1.1 cassette-config layer (aiolabs/satmachineadmin#29). Covers the pure pieces that don't need a live DB: - Pydantic validator behaviour on PublishCassettesPayload + the row / - upsert models (denomination key coercion, integer ranges, no-duplicate- - positions, wire-format round-trip) + upsert models (position key coercion, integer ranges, multiple-same- + denomination payloads, wire-format round-trip) - _should_apply_bootstrap_state dedup helper (extracted from apply_bootstrap_state so the relay-re-delivery decision is testable without a database round-trip) @@ -14,6 +14,11 @@ machine ordering, etc.) follow the project convention from test_deposit_currency.py: "Layer 2 is an endpoint-level behaviour better covered by an integration test against a running LNbits; tracked in #26 as a follow-up." Smoke-tested manually via the dev container. + +Wire shape pivot from m007 → m008 is the v1.1 coordination point per +coord-log 2026-05-30T18:30Z + 18:45Z: position is the row identity, +denomination + count are operator-editable per row, multiple same-denom +cassettes are valid. """ import pytest @@ -34,116 +39,127 @@ from ..models import ( class TestPublishCassettesPayload: """The kind-30078 content payload, bidirectional (operator→ATM and ATM→operator share the shape). String JSON keys must coerce to int; - duplicate positions must reject; per-row int constraints enforced.""" + per-row int constraints enforced; multiple same-denom rows are valid.""" def test_happy_path_coerces_string_keys_to_int(self): p = PublishCassettesPayload( - denominations={ - "20": {"position": 1, "count": 49}, - "50": {"position": 2, "count": 100}, + positions={ + "1": {"denomination": 20, "count": 49}, + "2": {"denomination": 50, "count": 100}, } ) - assert set(p.denominations.keys()) == {20, 50} - assert p.denominations[20].position == 1 - assert p.denominations[20].count == 49 - assert p.denominations[50].count == 100 + assert set(p.positions.keys()) == {1, 2} + assert p.positions[1].denomination == 20 + assert p.positions[1].count == 49 + assert p.positions[2].denomination == 50 + assert p.positions[2].count == 100 def test_wire_dict_round_trip_restringifies_keys(self): - """to_wire_dict() must restringify denomination keys so the - resulting JSON is parseable by clients (including the ATM-side - nostr-tools NIP-44 v2 consumer per the byte-compat cross-test).""" + """to_wire_dict() must restringify position keys so the resulting + JSON is parseable by clients (including the ATM-side nostr-tools + NIP-44 v2 consumer per the byte-compat cross-test).""" original = PublishCassettesPayload( - denominations={ - "20": {"position": 1, "count": 49}, - "50": {"position": 2, "count": 100}, + positions={ + "1": {"denomination": 20, "count": 49}, + "2": {"denomination": 50, "count": 100}, } ) wire = original.to_wire_dict() assert wire == { - "denominations": { - "20": {"position": 1, "count": 49}, - "50": {"position": 2, "count": 100}, + "positions": { + "1": {"denomination": 20, "count": 49}, + "2": {"denomination": 50, "count": 100}, } } # And the wire form round-trips back through the parser cleanly. reparsed = PublishCassettesPayload(**wire) - assert reparsed.denominations == original.denominations + assert reparsed.positions == original.positions - def test_rejects_non_int_key(self): + def test_accepts_multiple_same_denomination_cassettes(self): + """v1.1 operational case: real machines have N cassettes loaded + with the same denomination for cash-out throughput. The wire shape + must accept this, and we explicitly do NOT validate uniqueness on + denomination. Coord-log 2026-05-30T18:45Z bitspire response.""" + p = PublishCassettesPayload( + positions={ + "1": {"denomination": 20, "count": 100}, + "2": {"denomination": 20, "count": 100}, + "3": {"denomination": 50, "count": 50}, + "4": {"denomination": 100, "count": 25}, + } + ) + assert len(p.positions) == 4 + denoms = [row.denomination for row in p.positions.values()] + assert denoms.count(20) == 2 # two $20 cassettes + assert sorted(denoms) == [20, 20, 50, 100] + + def test_rejects_non_int_position_key(self): with pytest.raises(ValueError) as exc: PublishCassettesPayload( - denominations={"abc": {"position": 1, "count": 1}} + positions={"abc": {"denomination": 20, "count": 1}} ) assert "is not an int" in str(exc.value) - def test_rejects_non_positive_denomination(self): + def test_rejects_non_positive_position(self): with pytest.raises(ValueError) as exc: PublishCassettesPayload( - denominations={"0": {"position": 1, "count": 1}} + positions={"0": {"denomination": 20, "count": 1}} ) - assert "denomination must be > 0" in str(exc.value) + assert "position must be > 0" in str(exc.value) - def test_rejects_negative_denomination(self): + def test_rejects_negative_position(self): with pytest.raises(ValueError) as exc: PublishCassettesPayload( - denominations={"-20": {"position": 1, "count": 1}} + positions={"-1": {"denomination": 20, "count": 1}} ) - assert "denomination must be > 0" in str(exc.value) - - def test_rejects_duplicate_position(self): - """Two cassettes can't occupy the same physical slot. The schema - PK is (machine_id, denomination), so duplicates land via the - payload; reject at the validator layer before the publish path - builds an event the ATM will misinterpret.""" - with pytest.raises(ValueError) as exc: - PublishCassettesPayload( - denominations={ - "20": {"position": 1, "count": 49}, - "50": {"position": 1, "count": 100}, - } - ) - assert "duplicate position" in str(exc.value) + assert "position must be > 0" in str(exc.value) def test_rejects_negative_count(self): with pytest.raises(ValueError): PublishCassettesPayload( - denominations={"20": {"position": 1, "count": -1}} + positions={"1": {"denomination": 20, "count": -1}} ) - def test_rejects_zero_position(self): + def test_rejects_zero_denomination(self): with pytest.raises(ValueError): PublishCassettesPayload( - denominations={"20": {"position": 0, "count": 1}} + positions={"1": {"denomination": 0, "count": 49}} + ) + + def test_rejects_negative_denomination(self): + with pytest.raises(ValueError): + PublishCassettesPayload( + positions={"1": {"denomination": -20, "count": 49}} ) def test_allows_zero_count(self): """An empty cassette is a legal state — operator must be able to record `count=0` after a dispatcher pulled the cassette mid-day.""" p = PublishCassettesPayload( - denominations={"20": {"position": 1, "count": 0}} + positions={"1": {"denomination": 20, "count": 0}} ) - assert p.denominations[20].count == 0 + assert p.positions[1].count == 0 # ============================================================================= -# CassettePayloadRow — per-row int constraints (single-row tests) +# CassettePayloadRow — per-row int constraints # ============================================================================= class TestCassettePayloadRow: def test_happy_path(self): - row = CassettePayloadRow(position=1, count=49) - assert row.position == 1 + row = CassettePayloadRow(denomination=20, count=49) + assert row.denomination == 20 assert row.count == 49 - @pytest.mark.parametrize("bad_position", [0, -1, -100]) - def test_rejects_non_positive_position(self, bad_position): + @pytest.mark.parametrize("bad_denom", [0, -1, -100]) + def test_rejects_non_positive_denomination(self, bad_denom): with pytest.raises(ValueError): - CassettePayloadRow(position=bad_position, count=1) + CassettePayloadRow(denomination=bad_denom, count=1) def test_rejects_negative_count(self): with pytest.raises(ValueError): - CassettePayloadRow(position=1, count=-1) + CassettePayloadRow(denomination=20, count=-1) # ============================================================================= @@ -153,16 +169,19 @@ class TestCassettePayloadRow: class TestUpsertCassetteConfigData: """Operator-driven row edit. Both fields optional; same int constraints - as the wire-format row but applied independently per-edit.""" + as the wire-format row but applied independently per-edit. Position is + NOT editable — it's the row's identity (the hardware bay number).""" def test_partial_update_count_only(self): d = UpsertCassetteConfigData(count=80) assert d.count == 80 - assert d.position is None + assert d.denomination is None - def test_partial_update_position_only(self): - d = UpsertCassetteConfigData(position=3) - assert d.position == 3 + def test_partial_update_denomination_only(self): + """v1.1 operational case: operator records a cartridge swap at + refill — slot 1 was $20, dispatcher replaced with $50.""" + d = UpsertCassetteConfigData(denomination=50) + assert d.denomination == 50 assert d.count is None def test_empty_update_is_legal(self): @@ -170,15 +189,15 @@ class TestUpsertCassetteConfigData: circuits a no-op on empty payload (no SQL emitted).""" d = UpsertCassetteConfigData() assert d.count is None - assert d.position is None + assert d.denomination is None def test_rejects_negative_count(self): with pytest.raises(ValueError): UpsertCassetteConfigData(count=-1) - def test_rejects_non_positive_position(self): + def test_rejects_non_positive_denomination(self): with pytest.raises(ValueError): - UpsertCassetteConfigData(position=0) + UpsertCassetteConfigData(denomination=0) # ============================================================================= @@ -191,7 +210,7 @@ class TestShouldApplyBootstrapState: decision is testable without a DB. Logic: apply if-and-only-if the existing row's state_event_id differs from the incoming event_id. - In v1 the ATM publishes the bootstrap event exactly once per machine, + In v1.1 the ATM publishes the bootstrap event exactly once per machine, so this is sufficient for replay protection. v2 will need a `last_state_created_at` watermark in addition (per bitspire's `meta.lastKnownConfigCreatedAt` on the ATM side) — flagged in #29's diff --git a/tests/test_cassette_state_consumer.py b/tests/test_cassette_state_consumer.py index 4cd228e..9d3739a 100644 --- a/tests/test_cassette_state_consumer.py +++ b/tests/test_cassette_state_consumer.py @@ -5,13 +5,14 @@ and `cassette_transport.decrypt_and_parse_state_event`). Covers the consumer-side validation path end-to-end without standing up the full nostrclient relay subscription: - happy path: signed event from a known ATM → decrypt → parse → returns - a PublishCassettesPayload - - sig-verify failure path (covered at the transport-decrypt level + the - handler-level rejection test) + a position-keyed PublishCassettesPayload + - multiple same-denom cassettes (v1.1 operational case) — round-trips - tampered ciphertext → CassetteEventDecodeError - - unknown sender pubkey → CassetteEventDecodeError (well-formed but + - wrong operator privkey → CassetteEventDecodeError (well-formed but decrypt fails because conversation key is wrong) - malformed pubkey → CassetteEventDecodeError + - missing fields → CassetteEventDecodeError + - decrypted garbage / wrong-shape JSON → CassetteEventDecodeError Full handler tests (the dispatch through verify_event → get_machine_by_atm_ pubkey_hex → apply_bootstrap_state) need a live LNbits DB; they're @@ -95,21 +96,40 @@ class TestDecryptAndParseStateEvent: def test_happy_path(self): payload = PublishCassettesPayload( - denominations={ - "20": {"position": 1, "count": 49}, - "50": {"position": 2, "count": 100}, + positions={ + "1": {"denomination": 20, "count": 49}, + "2": {"denomination": 50, "count": 100}, } ) event = _make_state_event(payload) recovered = decrypt_and_parse_state_event(event, _OP_SEC) - assert sorted(recovered.denominations.keys()) == [20, 50] - assert recovered.denominations[20].position == 1 - assert recovered.denominations[20].count == 49 - assert recovered.denominations[50].count == 100 + assert sorted(recovered.positions.keys()) == [1, 2] + assert recovered.positions[1].denomination == 20 + assert recovered.positions[1].count == 49 + assert recovered.positions[2].denomination == 50 + assert recovered.positions[2].count == 100 + + def test_round_trips_multiple_same_denomination(self): + """v1.1 operational case from coord-log 18:45Z: real machines + load multiple cassettes with the same denomination.""" + payload = PublishCassettesPayload( + positions={ + "1": {"denomination": 20, "count": 100}, + "2": {"denomination": 20, "count": 100}, + "3": {"denomination": 20, "count": 100}, + "4": {"denomination": 20, "count": 100}, + } + ) + event = _make_state_event(payload) + recovered = decrypt_and_parse_state_event(event, _OP_SEC) + assert len(recovered.positions) == 4 + for pos in (1, 2, 3, 4): + assert recovered.positions[pos].denomination == 20 + assert recovered.positions[pos].count == 100 def test_tampered_content_rejected(self): payload = PublishCassettesPayload( - denominations={"20": {"position": 1, "count": 49}} + positions={"1": {"denomination": 20, "count": 49}} ) event = _make_state_event(payload) # Flip a base64 character — corrupts the ciphertext or MAC @@ -124,7 +144,7 @@ class TestDecryptAndParseStateEvent: different hmac_key, so MAC verification inside NIP-44 v2 decrypt fails — surfaced as CassetteEventDecodeError.""" payload = PublishCassettesPayload( - denominations={"20": {"position": 1, "count": 49}} + positions={"1": {"denomination": 20, "count": 49}} ) event = _make_state_event(payload) wrong_sec = "00" * 31 + "03" @@ -133,7 +153,7 @@ class TestDecryptAndParseStateEvent: def test_malformed_sender_pubkey_rejected(self): payload = PublishCassettesPayload( - denominations={"20": {"position": 1, "count": 49}} + positions={"1": {"denomination": 20, "count": 49}} ) event = _make_state_event(payload) event["pubkey"] = "not-a-real-pubkey" @@ -142,7 +162,9 @@ class TestDecryptAndParseStateEvent: def test_missing_content_rejected(self): event = _make_state_event( - PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}}) + PublishCassettesPayload( + positions={"1": {"denomination": 20, "count": 49}} + ) ) del event["content"] with pytest.raises(CassetteEventDecodeError): @@ -150,7 +172,9 @@ class TestDecryptAndParseStateEvent: def test_missing_pubkey_rejected(self): event = _make_state_event( - PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}}) + PublishCassettesPayload( + positions={"1": {"denomination": 20, "count": 49}} + ) ) del event["pubkey"] with pytest.raises(CassetteEventDecodeError): @@ -159,7 +183,6 @@ class TestDecryptAndParseStateEvent: def test_decrypted_garbage_json_rejected(self): """If the plaintext decrypts but isn't JSON, we surface an error rather than crashing the consumer loop.""" - # Encrypt something that isn't JSON ck = get_conversation_key(_ATM_SEC, _OP_PUB) bad_plaintext_event = { "kind": 30078, @@ -176,7 +199,7 @@ class TestDecryptAndParseStateEvent: assert "JSON" in str(exc.value) or "didn't validate" in str(exc.value) def test_decrypted_json_with_wrong_shape_rejected(self): - """Well-formed JSON but missing the 'denominations' field is + """Well-formed JSON but missing the 'positions' field is a payload-shape failure, not a decrypt failure.""" ck = get_conversation_key(_ATM_SEC, _OP_PUB) bad_shape_event = { diff --git a/tests/test_nip44_v2.py b/tests/test_nip44_v2.py index f3b27b9..2188f4f 100644 --- a/tests/test_nip44_v2.py +++ b/tests/test_nip44_v2.py @@ -308,6 +308,16 @@ _BITSPIRE_FIXTURE = { } +@pytest.mark.skip( + reason=( + "v1.1 wire-shape pivot (coord-log 2026-05-30T18:30Z + 18:45Z): the " + "13:15Z fixture above is encoded with the old `{denominations: ...}` " + "wire shape; the v1.1 wire shape is `{positions: {: " + "{denomination, count}}}`. Bitspire will post a fresh fixture against " + "the v1.1 shape; once posted, swap `_BITSPIRE_FIXTURE` for the new " + "JSON and drop this skip (v1.1 commit g on the PR)." + ) +) class TestBitspireCrossTest: """Byte-compat cross-test between our hand-rolled NIP-44 v2 (`nip44.py`) and the nostr-tools NIP-44 v2 impl that bitspire uses on the ATM side