feat(v2): operator-side cassette inventory v1.1 + signer.nip44_* migration (#29) #30
3 changed files with 132 additions and 80 deletions
test(v2): rewrite cassette tests for v1.1 position-keyed wire shape (#29 v1.1)
Some checks failed
ci.yml / test(v2): rewrite cassette tests for v1.1 position-keyed wire shape (#29 v1.1) (pull_request) Failing after 0s
Some checks failed
ci.yml / test(v2): rewrite cassette tests for v1.1 position-keyed wire shape (#29 v1.1) (pull_request) Failing after 0s
The wire-shape pivot (m007 denomination-keyed → m008 position-keyed)
needs the unit test surface re-written to match:
test_cassette_configs.py
- PublishCassettesPayload tests pivot to positions-keyed input.
Validators reject non-int / non-positive position keys, negative
denom, negative count. Zero count allowed (empty cassette).
- NEW: test_accepts_multiple_same_denomination_cassettes — pins the
v1.1 operational requirement (real machines load 4×$20 for cash-out
throughput) per coord-log 18:45Z. No denom-unique validator.
- CassettePayloadRow tests pivot to the new field shape
(denomination + count, no position).
- UpsertCassetteConfigData tests cover edit-denomination (the v1.1
"operator swaps a cartridge during refill" scenario) and edit-count.
Position no longer in the model.
test_cassette_state_consumer.py
- _make_state_event helper builds {"positions": {...}} ciphertext.
- Happy-path assertion checks p.positions keys + denomination/count
per row.
- NEW: test_round_trips_multiple_same_denomination — covers the v1.1
four-of-the-same case through encrypt → decrypt → parse.
- All negative paths (tamper, wrong privkey, malformed pubkey,
missing fields, garbage JSON, wrong shape) carry over with the new
payload shape.
- d-tag tests unchanged (position vs denomination isn't on the d-tag).
test_nip44_v2.py
- TestBitspireCrossTest temporarily re-skipped at the class level: the
13:15Z fixture is encoded with the v1 denomination-keyed shape;
bitspire's posting a v1.1 fixture and commit g will swap +
unskip.
Total: 148 passed, 3 skipped (bitspire cross-test pending the v1.1
fixture from bitspire), 1 pre-existing async-plugin failure unchanged.
Branch tip is now functionally green (the pre-existing async failure
predates this PR + can't be addressed without a pytest plugin install).
Pending commit g for the cross-test fixture re-wire when bitspire posts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
commit
1cebefcde5
|
|
@ -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:
|
Covers the pure pieces that don't need a live DB:
|
||||||
- Pydantic validator behaviour on PublishCassettesPayload + the row /
|
- Pydantic validator behaviour on PublishCassettesPayload + the row /
|
||||||
upsert models (denomination key coercion, integer ranges, no-duplicate-
|
upsert models (position key coercion, integer ranges, multiple-same-
|
||||||
positions, wire-format round-trip)
|
denomination payloads, wire-format round-trip)
|
||||||
- _should_apply_bootstrap_state dedup helper (extracted from
|
- _should_apply_bootstrap_state dedup helper (extracted from
|
||||||
apply_bootstrap_state so the relay-re-delivery decision is testable
|
apply_bootstrap_state so the relay-re-delivery decision is testable
|
||||||
without a database round-trip)
|
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
|
test_deposit_currency.py: "Layer 2 is an endpoint-level behaviour better
|
||||||
covered by an integration test against a running LNbits; tracked in #26
|
covered by an integration test against a running LNbits; tracked in #26
|
||||||
as a follow-up." Smoke-tested manually via the dev container.
|
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
|
import pytest
|
||||||
|
|
@ -34,116 +39,127 @@ from ..models import (
|
||||||
class TestPublishCassettesPayload:
|
class TestPublishCassettesPayload:
|
||||||
"""The kind-30078 content payload, bidirectional (operator→ATM and
|
"""The kind-30078 content payload, bidirectional (operator→ATM and
|
||||||
ATM→operator share the shape). String JSON keys must coerce to int;
|
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):
|
def test_happy_path_coerces_string_keys_to_int(self):
|
||||||
p = PublishCassettesPayload(
|
p = PublishCassettesPayload(
|
||||||
denominations={
|
positions={
|
||||||
"20": {"position": 1, "count": 49},
|
"1": {"denomination": 20, "count": 49},
|
||||||
"50": {"position": 2, "count": 100},
|
"2": {"denomination": 50, "count": 100},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
assert set(p.denominations.keys()) == {20, 50}
|
assert set(p.positions.keys()) == {1, 2}
|
||||||
assert p.denominations[20].position == 1
|
assert p.positions[1].denomination == 20
|
||||||
assert p.denominations[20].count == 49
|
assert p.positions[1].count == 49
|
||||||
assert p.denominations[50].count == 100
|
assert p.positions[2].denomination == 50
|
||||||
|
assert p.positions[2].count == 100
|
||||||
|
|
||||||
def test_wire_dict_round_trip_restringifies_keys(self):
|
def test_wire_dict_round_trip_restringifies_keys(self):
|
||||||
"""to_wire_dict() must restringify denomination keys so the
|
"""to_wire_dict() must restringify position keys so the resulting
|
||||||
resulting JSON is parseable by clients (including the ATM-side
|
JSON is parseable by clients (including the ATM-side nostr-tools
|
||||||
nostr-tools NIP-44 v2 consumer per the byte-compat cross-test)."""
|
NIP-44 v2 consumer per the byte-compat cross-test)."""
|
||||||
original = PublishCassettesPayload(
|
original = PublishCassettesPayload(
|
||||||
denominations={
|
positions={
|
||||||
"20": {"position": 1, "count": 49},
|
"1": {"denomination": 20, "count": 49},
|
||||||
"50": {"position": 2, "count": 100},
|
"2": {"denomination": 50, "count": 100},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
wire = original.to_wire_dict()
|
wire = original.to_wire_dict()
|
||||||
assert wire == {
|
assert wire == {
|
||||||
"denominations": {
|
"positions": {
|
||||||
"20": {"position": 1, "count": 49},
|
"1": {"denomination": 20, "count": 49},
|
||||||
"50": {"position": 2, "count": 100},
|
"2": {"denomination": 50, "count": 100},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
# And the wire form round-trips back through the parser cleanly.
|
# And the wire form round-trips back through the parser cleanly.
|
||||||
reparsed = PublishCassettesPayload(**wire)
|
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:
|
with pytest.raises(ValueError) as exc:
|
||||||
PublishCassettesPayload(
|
PublishCassettesPayload(
|
||||||
denominations={"abc": {"position": 1, "count": 1}}
|
positions={"abc": {"denomination": 20, "count": 1}}
|
||||||
)
|
)
|
||||||
assert "is not an int" in str(exc.value)
|
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:
|
with pytest.raises(ValueError) as exc:
|
||||||
PublishCassettesPayload(
|
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:
|
with pytest.raises(ValueError) as exc:
|
||||||
PublishCassettesPayload(
|
PublishCassettesPayload(
|
||||||
denominations={"-20": {"position": 1, "count": 1}}
|
positions={"-1": {"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_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)
|
|
||||||
|
|
||||||
def test_rejects_negative_count(self):
|
def test_rejects_negative_count(self):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
PublishCassettesPayload(
|
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):
|
with pytest.raises(ValueError):
|
||||||
PublishCassettesPayload(
|
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):
|
def test_allows_zero_count(self):
|
||||||
"""An empty cassette is a legal state — operator must be able to
|
"""An empty cassette is a legal state — operator must be able to
|
||||||
record `count=0` after a dispatcher pulled the cassette mid-day."""
|
record `count=0` after a dispatcher pulled the cassette mid-day."""
|
||||||
p = PublishCassettesPayload(
|
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:
|
class TestCassettePayloadRow:
|
||||||
def test_happy_path(self):
|
def test_happy_path(self):
|
||||||
row = CassettePayloadRow(position=1, count=49)
|
row = CassettePayloadRow(denomination=20, count=49)
|
||||||
assert row.position == 1
|
assert row.denomination == 20
|
||||||
assert row.count == 49
|
assert row.count == 49
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad_position", [0, -1, -100])
|
@pytest.mark.parametrize("bad_denom", [0, -1, -100])
|
||||||
def test_rejects_non_positive_position(self, bad_position):
|
def test_rejects_non_positive_denomination(self, bad_denom):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
CassettePayloadRow(position=bad_position, count=1)
|
CassettePayloadRow(denomination=bad_denom, count=1)
|
||||||
|
|
||||||
def test_rejects_negative_count(self):
|
def test_rejects_negative_count(self):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
CassettePayloadRow(position=1, count=-1)
|
CassettePayloadRow(denomination=20, count=-1)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -153,16 +169,19 @@ class TestCassettePayloadRow:
|
||||||
|
|
||||||
class TestUpsertCassetteConfigData:
|
class TestUpsertCassetteConfigData:
|
||||||
"""Operator-driven row edit. Both fields optional; same int constraints
|
"""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):
|
def test_partial_update_count_only(self):
|
||||||
d = UpsertCassetteConfigData(count=80)
|
d = UpsertCassetteConfigData(count=80)
|
||||||
assert d.count == 80
|
assert d.count == 80
|
||||||
assert d.position is None
|
assert d.denomination is None
|
||||||
|
|
||||||
def test_partial_update_position_only(self):
|
def test_partial_update_denomination_only(self):
|
||||||
d = UpsertCassetteConfigData(position=3)
|
"""v1.1 operational case: operator records a cartridge swap at
|
||||||
assert d.position == 3
|
refill — slot 1 was $20, dispatcher replaced with $50."""
|
||||||
|
d = UpsertCassetteConfigData(denomination=50)
|
||||||
|
assert d.denomination == 50
|
||||||
assert d.count is None
|
assert d.count is None
|
||||||
|
|
||||||
def test_empty_update_is_legal(self):
|
def test_empty_update_is_legal(self):
|
||||||
|
|
@ -170,15 +189,15 @@ class TestUpsertCassetteConfigData:
|
||||||
circuits a no-op on empty payload (no SQL emitted)."""
|
circuits a no-op on empty payload (no SQL emitted)."""
|
||||||
d = UpsertCassetteConfigData()
|
d = UpsertCassetteConfigData()
|
||||||
assert d.count is None
|
assert d.count is None
|
||||||
assert d.position is None
|
assert d.denomination is None
|
||||||
|
|
||||||
def test_rejects_negative_count(self):
|
def test_rejects_negative_count(self):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
UpsertCassetteConfigData(count=-1)
|
UpsertCassetteConfigData(count=-1)
|
||||||
|
|
||||||
def test_rejects_non_positive_position(self):
|
def test_rejects_non_positive_denomination(self):
|
||||||
with pytest.raises(ValueError):
|
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
|
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.
|
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
|
so this is sufficient for replay protection. v2 will need a
|
||||||
`last_state_created_at` watermark in addition (per bitspire's
|
`last_state_created_at` watermark in addition (per bitspire's
|
||||||
`meta.lastKnownConfigCreatedAt` on the ATM side) — flagged in #29's
|
`meta.lastKnownConfigCreatedAt` on the ATM side) — flagged in #29's
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,14 @@ and `cassette_transport.decrypt_and_parse_state_event`).
|
||||||
Covers the consumer-side validation path end-to-end without standing up
|
Covers the consumer-side validation path end-to-end without standing up
|
||||||
the full nostrclient relay subscription:
|
the full nostrclient relay subscription:
|
||||||
- happy path: signed event from a known ATM → decrypt → parse → returns
|
- happy path: signed event from a known ATM → decrypt → parse → returns
|
||||||
a PublishCassettesPayload
|
a position-keyed PublishCassettesPayload
|
||||||
- sig-verify failure path (covered at the transport-decrypt level + the
|
- multiple same-denom cassettes (v1.1 operational case) — round-trips
|
||||||
handler-level rejection test)
|
|
||||||
- tampered ciphertext → CassetteEventDecodeError
|
- tampered ciphertext → CassetteEventDecodeError
|
||||||
- unknown sender pubkey → CassetteEventDecodeError (well-formed but
|
- wrong operator privkey → CassetteEventDecodeError (well-formed but
|
||||||
decrypt fails because conversation key is wrong)
|
decrypt fails because conversation key is wrong)
|
||||||
- malformed pubkey → CassetteEventDecodeError
|
- malformed pubkey → CassetteEventDecodeError
|
||||||
|
- missing fields → CassetteEventDecodeError
|
||||||
|
- decrypted garbage / wrong-shape JSON → CassetteEventDecodeError
|
||||||
|
|
||||||
Full handler tests (the dispatch through verify_event → get_machine_by_atm_
|
Full handler tests (the dispatch through verify_event → get_machine_by_atm_
|
||||||
pubkey_hex → apply_bootstrap_state) need a live LNbits DB; they're
|
pubkey_hex → apply_bootstrap_state) need a live LNbits DB; they're
|
||||||
|
|
@ -95,21 +96,40 @@ class TestDecryptAndParseStateEvent:
|
||||||
|
|
||||||
def test_happy_path(self):
|
def test_happy_path(self):
|
||||||
payload = PublishCassettesPayload(
|
payload = PublishCassettesPayload(
|
||||||
denominations={
|
positions={
|
||||||
"20": {"position": 1, "count": 49},
|
"1": {"denomination": 20, "count": 49},
|
||||||
"50": {"position": 2, "count": 100},
|
"2": {"denomination": 50, "count": 100},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
event = _make_state_event(payload)
|
event = _make_state_event(payload)
|
||||||
recovered = decrypt_and_parse_state_event(event, _OP_SEC)
|
recovered = decrypt_and_parse_state_event(event, _OP_SEC)
|
||||||
assert sorted(recovered.denominations.keys()) == [20, 50]
|
assert sorted(recovered.positions.keys()) == [1, 2]
|
||||||
assert recovered.denominations[20].position == 1
|
assert recovered.positions[1].denomination == 20
|
||||||
assert recovered.denominations[20].count == 49
|
assert recovered.positions[1].count == 49
|
||||||
assert recovered.denominations[50].count == 100
|
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):
|
def test_tampered_content_rejected(self):
|
||||||
payload = PublishCassettesPayload(
|
payload = PublishCassettesPayload(
|
||||||
denominations={"20": {"position": 1, "count": 49}}
|
positions={"1": {"denomination": 20, "count": 49}}
|
||||||
)
|
)
|
||||||
event = _make_state_event(payload)
|
event = _make_state_event(payload)
|
||||||
# Flip a base64 character — corrupts the ciphertext or MAC
|
# 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
|
different hmac_key, so MAC verification inside NIP-44 v2 decrypt
|
||||||
fails — surfaced as CassetteEventDecodeError."""
|
fails — surfaced as CassetteEventDecodeError."""
|
||||||
payload = PublishCassettesPayload(
|
payload = PublishCassettesPayload(
|
||||||
denominations={"20": {"position": 1, "count": 49}}
|
positions={"1": {"denomination": 20, "count": 49}}
|
||||||
)
|
)
|
||||||
event = _make_state_event(payload)
|
event = _make_state_event(payload)
|
||||||
wrong_sec = "00" * 31 + "03"
|
wrong_sec = "00" * 31 + "03"
|
||||||
|
|
@ -133,7 +153,7 @@ class TestDecryptAndParseStateEvent:
|
||||||
|
|
||||||
def test_malformed_sender_pubkey_rejected(self):
|
def test_malformed_sender_pubkey_rejected(self):
|
||||||
payload = PublishCassettesPayload(
|
payload = PublishCassettesPayload(
|
||||||
denominations={"20": {"position": 1, "count": 49}}
|
positions={"1": {"denomination": 20, "count": 49}}
|
||||||
)
|
)
|
||||||
event = _make_state_event(payload)
|
event = _make_state_event(payload)
|
||||||
event["pubkey"] = "not-a-real-pubkey"
|
event["pubkey"] = "not-a-real-pubkey"
|
||||||
|
|
@ -142,7 +162,9 @@ class TestDecryptAndParseStateEvent:
|
||||||
|
|
||||||
def test_missing_content_rejected(self):
|
def test_missing_content_rejected(self):
|
||||||
event = _make_state_event(
|
event = _make_state_event(
|
||||||
PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}})
|
PublishCassettesPayload(
|
||||||
|
positions={"1": {"denomination": 20, "count": 49}}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
del event["content"]
|
del event["content"]
|
||||||
with pytest.raises(CassetteEventDecodeError):
|
with pytest.raises(CassetteEventDecodeError):
|
||||||
|
|
@ -150,7 +172,9 @@ class TestDecryptAndParseStateEvent:
|
||||||
|
|
||||||
def test_missing_pubkey_rejected(self):
|
def test_missing_pubkey_rejected(self):
|
||||||
event = _make_state_event(
|
event = _make_state_event(
|
||||||
PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}})
|
PublishCassettesPayload(
|
||||||
|
positions={"1": {"denomination": 20, "count": 49}}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
del event["pubkey"]
|
del event["pubkey"]
|
||||||
with pytest.raises(CassetteEventDecodeError):
|
with pytest.raises(CassetteEventDecodeError):
|
||||||
|
|
@ -159,7 +183,6 @@ class TestDecryptAndParseStateEvent:
|
||||||
def test_decrypted_garbage_json_rejected(self):
|
def test_decrypted_garbage_json_rejected(self):
|
||||||
"""If the plaintext decrypts but isn't JSON, we surface an error
|
"""If the plaintext decrypts but isn't JSON, we surface an error
|
||||||
rather than crashing the consumer loop."""
|
rather than crashing the consumer loop."""
|
||||||
# Encrypt something that isn't JSON
|
|
||||||
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
|
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
|
||||||
bad_plaintext_event = {
|
bad_plaintext_event = {
|
||||||
"kind": 30078,
|
"kind": 30078,
|
||||||
|
|
@ -176,7 +199,7 @@ class TestDecryptAndParseStateEvent:
|
||||||
assert "JSON" in str(exc.value) or "didn't validate" in str(exc.value)
|
assert "JSON" in str(exc.value) or "didn't validate" in str(exc.value)
|
||||||
|
|
||||||
def test_decrypted_json_with_wrong_shape_rejected(self):
|
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."""
|
a payload-shape failure, not a decrypt failure."""
|
||||||
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
|
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
|
||||||
bad_shape_event = {
|
bad_shape_event = {
|
||||||
|
|
|
||||||
|
|
@ -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: {<pos>: "
|
||||||
|
"{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:
|
class TestBitspireCrossTest:
|
||||||
"""Byte-compat cross-test between our hand-rolled NIP-44 v2 (`nip44.py`)
|
"""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
|
and the nostr-tools NIP-44 v2 impl that bitspire uses on the ATM side
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue