chore(v2): lint pass — black + ruff auto-fix + mypy regressions (#29 v1.1)
Some checks failed
ci.yml / chore(v2): lint pass — black + ruff auto-fix + mypy regressions (#29 v1.1) (pull_request) Failing after 0s
Some checks failed
ci.yml / chore(v2): lint pass — black + ruff auto-fix + mypy regressions (#29 v1.1) (pull_request) Failing after 0s
Pre-merge lint hygiene on the PR #30 touched files:
- `black` reformatted 9 files (cassette_transport, crud, models, tasks,
views_api, nip44, all 3 cassette test files, migrations). Cosmetic:
line lengths, trailing commas, multi-line argument layout.
- `ruff check --fix` cleared 176 of 202 errors auto-fixed. Mostly
`UP006` `typing.Optional` → `| None` modernization, `I001` import
sort order, `UP035` typing-extensions cleanup.
- Two new mypy regressions introduced by the migration commit dcb7de0
fixed:
- `crud.py:apply_bootstrap_state` — annotated `existing_first: dict
| None` on the dedup fetch.
- `tasks.py:_cassette_consumer_tick` — `# type: ignore[arg-type]` on
the `nostr_client.relay_manager.add_subscription` call; nostrclient's
upstream typing declares `list[str]` for filters but the actual
Nostr protocol takes `list[<filter-dict>]`. The runtime accepts it
(live smoke at 13:43Z dispatched `nip44_decrypt` cleanly through
this subscription); the typing mismatch is upstream's.
Remaining lint state, intentionally not addressed in this commit
(all pre-existing baseline, not regressions):
- 8 mypy errors in `calculations.py` + the unchanged-by-this-PR parts
of `crud.py` — pre-existing on v2-bitspire.
- 26 ruff style warnings: 14 are N805 false-positives on Pydantic
validators (`cls` first-arg is correct for `@validator`-decorated
methods); 4 are N818 exception-name-suffix preferences on my new
exception classes (renaming would touch many call sites; keep
`OperatorIdentityMissing` / `SignerUnavailable` / `RelayUnavailable`
/ `_NostrclientUnavailable` as-is for clarity); 5 are E501 line-too-
long on docstrings (the long lines are formatted for clarity);
1 RUF002 unicode-minus in a docstring.
Tests: 155 passed, 1 pre-existing async-plugin failure unchanged.
Live smoke (both publish + consume directions through the bunker)
unaffected — this is purely a code-style pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dcb7de0c27
commit
d448fab0d2
10 changed files with 249 additions and 352 deletions
|
|
@ -30,7 +30,6 @@ from ..models import (
|
|||
UpsertCassetteConfigData,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PublishCassettesPayload — wire-shape validators
|
||||
# =============================================================================
|
||||
|
|
@ -95,49 +94,35 @@ class TestPublishCassettesPayload:
|
|||
|
||||
def test_rejects_non_int_position_key(self):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
PublishCassettesPayload(
|
||||
positions={"abc": {"denomination": 20, "count": 1}}
|
||||
)
|
||||
PublishCassettesPayload(positions={"abc": {"denomination": 20, "count": 1}})
|
||||
assert "is not an int" in str(exc.value)
|
||||
|
||||
def test_rejects_non_positive_position(self):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
PublishCassettesPayload(
|
||||
positions={"0": {"denomination": 20, "count": 1}}
|
||||
)
|
||||
PublishCassettesPayload(positions={"0": {"denomination": 20, "count": 1}})
|
||||
assert "position must be > 0" in str(exc.value)
|
||||
|
||||
def test_rejects_negative_position(self):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
PublishCassettesPayload(
|
||||
positions={"-1": {"denomination": 20, "count": 1}}
|
||||
)
|
||||
PublishCassettesPayload(positions={"-1": {"denomination": 20, "count": 1}})
|
||||
assert "position must be > 0" in str(exc.value)
|
||||
|
||||
def test_rejects_negative_count(self):
|
||||
with pytest.raises(ValueError):
|
||||
PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": -1}}
|
||||
)
|
||||
PublishCassettesPayload(positions={"1": {"denomination": 20, "count": -1}})
|
||||
|
||||
def test_rejects_zero_denomination(self):
|
||||
with pytest.raises(ValueError):
|
||||
PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 0, "count": 49}}
|
||||
)
|
||||
PublishCassettesPayload(positions={"1": {"denomination": 0, "count": 49}})
|
||||
|
||||
def test_rejects_negative_denomination(self):
|
||||
with pytest.raises(ValueError):
|
||||
PublishCassettesPayload(
|
||||
positions={"1": {"denomination": -20, "count": 49}}
|
||||
)
|
||||
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(
|
||||
positions={"1": {"denomination": 20, "count": 0}}
|
||||
)
|
||||
p = PublishCassettesPayload(positions={"1": {"denomination": 20, "count": 0}})
|
||||
assert p.positions[1].count == 0
|
||||
|
||||
|
||||
|
|
@ -221,17 +206,13 @@ class TestShouldApplyBootstrapState:
|
|||
assert _should_apply_bootstrap_state(None, "new-event-id") is True
|
||||
|
||||
def test_applies_when_existing_event_id_differs(self):
|
||||
assert (
|
||||
_should_apply_bootstrap_state("old-event-id", "new-event-id") is True
|
||||
)
|
||||
assert _should_apply_bootstrap_state("old-event-id", "new-event-id") is True
|
||||
|
||||
def test_skips_when_existing_event_id_matches(self):
|
||||
"""The same bootstrap event re-delivered after a relay reconnect
|
||||
or satmachineadmin restart should no-op, not re-upsert the same
|
||||
rows (which would clobber any operator edits since)."""
|
||||
assert (
|
||||
_should_apply_bootstrap_state("same-event", "same-event") is False
|
||||
)
|
||||
assert _should_apply_bootstrap_state("same-event", "same-event") is False
|
||||
|
||||
def test_applies_when_existing_is_empty_string_and_incoming_is_id(self):
|
||||
"""Defensive — a sentinel empty-string existing_state_event_id
|
||||
|
|
|
|||
|
|
@ -30,11 +30,9 @@ convention (see test_deposit_currency.py rationale).
|
|||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
|
||||
import coincurve
|
||||
import pytest
|
||||
|
||||
from lnbits.core.services.nip46_bunker_client import (
|
||||
NsecBunkerRpcError,
|
||||
NsecBunkerTimeoutError,
|
||||
|
|
@ -59,7 +57,6 @@ from ..nip44 import (
|
|||
get_conversation_key,
|
||||
)
|
||||
|
||||
|
||||
# Canonical keys (integer 1 + integer 2, the paulmillr/nip44 reference pair).
|
||||
_OP_SEC = "00" * 31 + "01"
|
||||
_ATM_SEC = "00" * 31 + "02"
|
||||
|
|
@ -118,14 +115,10 @@ class _FakeLocalSignerStub:
|
|||
return True
|
||||
|
||||
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
|
||||
raise SignerUnavailableError(
|
||||
"LocalSigner does not implement nip44_encrypt"
|
||||
)
|
||||
raise SignerUnavailableError("LocalSigner does not implement nip44_encrypt")
|
||||
|
||||
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
|
||||
raise SignerUnavailableError(
|
||||
"LocalSigner does not implement nip44_decrypt"
|
||||
)
|
||||
raise SignerUnavailableError("LocalSigner does not implement nip44_decrypt")
|
||||
|
||||
|
||||
class _FakeRaisingSigner:
|
||||
|
|
@ -148,7 +141,7 @@ class _FakeRaisingSigner:
|
|||
|
||||
def _fake_account(
|
||||
signer_type: str = "RemoteBunkerSigner",
|
||||
prvkey: Optional[str] = None,
|
||||
prvkey: str | None = None,
|
||||
):
|
||||
"""Account-shaped duck-typed object. decrypt_and_parse_state_event +
|
||||
_nip44_decrypt_via_signer only read `.signer_type` and `.prvkey`; the
|
||||
|
|
@ -211,9 +204,7 @@ class TestDecryptViaBunkerSigner:
|
|||
account = _fake_account(signer_type="RemoteBunkerSigner")
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
|
||||
recovered = asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer))
|
||||
assert sorted(recovered.positions.keys()) == [1, 2]
|
||||
assert recovered.positions[1].denomination == 20
|
||||
assert recovered.positions[1].count == 49
|
||||
|
|
@ -235,9 +226,7 @@ class TestDecryptViaBunkerSigner:
|
|||
account = _fake_account()
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
|
||||
recovered = asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer))
|
||||
assert len(recovered.positions) == 4
|
||||
for pos in (1, 2, 3, 4):
|
||||
assert recovered.positions[pos].denomination == 20
|
||||
|
|
@ -264,9 +253,7 @@ class TestDecryptViaLocalSignerFallback:
|
|||
account = _fake_account(signer_type="LocalSigner", prvkey=_OP_SEC)
|
||||
signer = _FakeLocalSignerStub()
|
||||
|
||||
recovered = asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer))
|
||||
assert recovered.positions[1].denomination == 20
|
||||
assert recovered.positions[1].count == 49
|
||||
|
||||
|
|
@ -283,9 +270,7 @@ class TestDecryptViaLocalSignerFallback:
|
|||
signer = _FakeLocalSignerStub()
|
||||
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
|
||||
|
||||
def test_clientonlysigner_raises_decode_error(self):
|
||||
"""ClientSideOnlySigner has no server-side decrypt path at all;
|
||||
|
|
@ -295,15 +280,11 @@ class TestDecryptViaLocalSignerFallback:
|
|||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
account = _fake_account(
|
||||
signer_type="ClientSideOnlySigner", prvkey=None
|
||||
)
|
||||
account = _fake_account(signer_type="ClientSideOnlySigner", prvkey=None)
|
||||
signer = _FakeLocalSignerStub() # behaves the same way: raises
|
||||
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -326,13 +307,9 @@ class TestBunkerErrorMapping:
|
|||
)
|
||||
event = _make_state_event(payload)
|
||||
account = _fake_account()
|
||||
signer = _FakeRaisingSigner(
|
||||
NsecBunkerTimeoutError("bunker unreachable")
|
||||
)
|
||||
signer = _FakeRaisingSigner(NsecBunkerTimeoutError("bunker unreachable"))
|
||||
with pytest.raises(CassetteEventTransientError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
|
||||
|
||||
def test_rpc_reject_maps_to_decode_error(self):
|
||||
"""Bunker rejected the RPC (policy / MAC / config) →
|
||||
|
|
@ -347,9 +324,7 @@ class TestBunkerErrorMapping:
|
|||
NsecBunkerRpcError("bunker policy reject: kind 30078 not authorised")
|
||||
)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -371,9 +346,7 @@ class TestPayloadValidation:
|
|||
account = _fake_account()
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
|
||||
|
||||
def test_wrong_signer_privkey_rejected(self):
|
||||
"""Wrong privkey on the signer → wrong conversation key → MAC
|
||||
|
|
@ -389,37 +362,27 @@ class TestPayloadValidation:
|
|||
wrong_sec = "00" * 31 + "03"
|
||||
signer = _FakeBunkerSigner(wrong_sec)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
|
||||
|
||||
def test_missing_content_rejected(self):
|
||||
event = _make_state_event(
|
||||
PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
PublishCassettesPayload(positions={"1": {"denomination": 20, "count": 49}})
|
||||
)
|
||||
del event["content"]
|
||||
account = _fake_account()
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
|
||||
|
||||
def test_missing_pubkey_rejected(self):
|
||||
event = _make_state_event(
|
||||
PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
PublishCassettesPayload(positions={"1": {"denomination": 20, "count": 49}})
|
||||
)
|
||||
del event["pubkey"]
|
||||
account = _fake_account()
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
|
||||
|
||||
def test_decrypted_garbage_json_rejected(self):
|
||||
"""If plaintext decrypts cleanly but isn't valid JSON, surface
|
||||
|
|
@ -428,9 +391,7 @@ class TestPayloadValidation:
|
|||
event = {
|
||||
"kind": 30078,
|
||||
"pubkey": _ATM_PUB,
|
||||
"content": encrypt_with_conversation_key(
|
||||
"definitely not json", ck
|
||||
),
|
||||
"content": encrypt_with_conversation_key("definitely not json", ck),
|
||||
"tags": [],
|
||||
"created_at": 0,
|
||||
"id": "x",
|
||||
|
|
@ -438,9 +399,7 @@ class TestPayloadValidation:
|
|||
account = _fake_account()
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
|
||||
|
||||
def test_decrypted_wrong_shape_rejected(self):
|
||||
"""Well-formed JSON but missing 'positions' → payload-shape
|
||||
|
|
@ -457,9 +416,7 @@ class TestPayloadValidation:
|
|||
account = _fake_account()
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from ..nip44 import (
|
|||
get_conversation_key,
|
||||
)
|
||||
|
||||
|
||||
# Helper: derive a compressed-x-coord pubkey hex string from a secret hex.
|
||||
def _pub_hex(sec_hex: str) -> str:
|
||||
return (
|
||||
|
|
@ -222,7 +223,10 @@ class TestPaddingFormula:
|
|||
(32, 32),
|
||||
(33, 64), # > 32 → next chunk
|
||||
(64, 64),
|
||||
(65, 96), # chunk = 32 for L=65 (next_power(64) = 64; 64//8 = 8; max(32, 8) = 32)
|
||||
(
|
||||
65,
|
||||
96,
|
||||
), # chunk = 32 for L=65 (next_power(64) = 64; 64//8 = 8; max(32, 8) = 32)
|
||||
(100, 128),
|
||||
(128, 128),
|
||||
# L=129: next_power(128) = 1<<8 = 256; chunk = max(32, 256//8) = 32;
|
||||
|
|
@ -230,7 +234,10 @@ class TestPaddingFormula:
|
|||
(129, 160),
|
||||
(256, 256), # chunk = 32 for L=256 (next_power(255)=256; max(32, 32) = 32)
|
||||
(257, 320),
|
||||
(1000, 1024), # chunk = 128 for L=1000 (next_power(999)=1024; max(32, 128) = 128)
|
||||
(
|
||||
1000,
|
||||
1024,
|
||||
), # chunk = 128 for L=1000 (next_power(999)=1024; max(32, 128) = 128)
|
||||
],
|
||||
)
|
||||
def test_calc_padded_len(self, plaintext_len, expected_padded):
|
||||
|
|
@ -300,12 +307,8 @@ _BITSPIRE_FIXTURE = {
|
|||
],
|
||||
],
|
||||
"created_at": 1780173222,
|
||||
"pubkey": (
|
||||
"217bdc9a65b571c4d9b59da6227a7aa6ca5bbfd5280af791417c57a79d92852b"
|
||||
),
|
||||
"id": (
|
||||
"72c09f333386dd4ad6125f8c69823824eea50d8091b694458bcd60701517eece"
|
||||
),
|
||||
"pubkey": ("217bdc9a65b571c4d9b59da6227a7aa6ca5bbfd5280af791417c57a79d92852b"),
|
||||
"id": ("72c09f333386dd4ad6125f8c69823824eea50d8091b694458bcd60701517eece"),
|
||||
"sig": (
|
||||
"07ecafacf0169f074e564a999ee1c31446930b43391d007c4a1f9ef7ad890d6c"
|
||||
"2aa6e3ecc5318edeb5748fbd64c7ca33407099a97154e2ff7e0c626e48d71925"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue