diff --git a/src/le_agent_sdk/l402/__init__.py b/src/le_agent_sdk/l402/__init__.py index dad797c..dd28070 100644 --- a/src/le_agent_sdk/l402/__init__.py +++ b/src/le_agent_sdk/l402/__init__.py @@ -1,5 +1,15 @@ """L402 HTTP client for agent service settlement.""" -from le_agent_sdk.l402.client import L402Client +from le_agent_sdk.l402.client import ( + L402Client, + MppChallenge, + parse_mpp_challenge, + parse_payment_challenge, +) -__all__ = ["L402Client"] +__all__ = [ + "L402Client", + "MppChallenge", + "parse_mpp_challenge", + "parse_payment_challenge", +] diff --git a/src/le_agent_sdk/l402/client.py b/src/le_agent_sdk/l402/client.py index fe9f58e..a131dd8 100644 --- a/src/le_agent_sdk/l402/client.py +++ b/src/le_agent_sdk/l402/client.py @@ -35,11 +35,48 @@ def authorization_header(self) -> str: return f"L402 {self.macaroon}" +@dataclass(frozen=True) +class MppChallenge: + """MPP challenge from Payment WWW-Authenticate header.""" + + invoice: str + amount: Optional[str] = None + realm: Optional[str] = None + + # Pattern for parsing L402/LSAT challenges _CHALLENGE_RE = re.compile( r'(?:L402|LSAT)\s+' - r'macaroon="?(?P[^",\s]+)"?\s*,\s*' - r'invoice="?(?P[^",\s]+)"?', + r'macaroon\s*=\s*"?(?P[^",\s]+)"?\s*,\s*' + r'invoice\s*=\s*"?(?P[^",\s]+)"?', + re.IGNORECASE, +) + +# Patterns for parsing MPP (Machine Payments Protocol) challenges +# _AUTH_SCHEME_SPLIT splits a WWW-Authenticate value into individual challenges +# by detecting auth-scheme token boundaries (e.g. "Bearer ...", "Payment ..."). +_AUTH_SCHEME_SPLIT = re.compile( + r'(?:^|,\s*)(?=[A-Za-z][A-Za-z0-9!#$&\-^_`|~]*\s)', +) +# Match parameters inside a Payment challenge's parameter list, ensuring we +# only match full parameter names at proper boundaries (start-of-string, +# whitespace, or comma — covers the full RFC 7230 tchar set). +# Also supports both quoted and unquoted (bare token) auth-param values +# per HTTP auth header grammar. +_MPP_INVOICE_RE = re.compile( + r'(?:^|[\s,])invoice\s*=\s*"?(?P[^",\s]+)"?', + re.IGNORECASE, +) +_MPP_METHOD_RE = re.compile( + r'(?:^|[\s,])method\s*=\s*"?lightning"?(?=$|[\s,])', + re.IGNORECASE, +) +_MPP_AMOUNT_RE = re.compile( + r'(?:^|[\s,])amount\s*=\s*"?(?P[^",\s]+)"?', + re.IGNORECASE, +) +_MPP_REALM_RE = re.compile( + r'(?:^|[\s,])realm\s*=\s*"?(?P[^",\s]+)"?', re.IGNORECASE, ) @@ -68,6 +105,93 @@ def parse_l402_challenge(headers: dict[str, str]) -> Optional[L402Challenge]: ) +def _extract_payment_segment(header: str) -> Optional[str]: + """Extract only the Payment challenge segment from a WWW-Authenticate value. + + Splits the header at auth-scheme boundaries so that parameters from + other schemes (e.g. Bearer realm=...) are never included. + """ + # Split into individual challenge segments at auth-scheme boundaries + segments = _AUTH_SCHEME_SPLIT.split(header) + for segment in segments: + stripped = segment.strip().rstrip(",").strip() + if stripped.upper().startswith("PAYMENT "): + return stripped + return None + + +def parse_mpp_challenge(header: str) -> MppChallenge: + """Parse a Payment (MPP) challenge from a WWW-Authenticate header value. + + Args: + header: The WWW-Authenticate header value string. + + Returns: + Parsed MppChallenge. + + Raises: + ValueError: If the header is not a valid MPP challenge. + """ + payment_segment = _extract_payment_segment(header) + if payment_segment is None: + raise ValueError(f"Invalid MPP challenge: {header[:80]}") + + # Verify method="lightning" within the Payment segment + if not _MPP_METHOD_RE.search(payment_segment): + raise ValueError(f"Invalid MPP challenge: {header[:80]}") + + invoice_match = _MPP_INVOICE_RE.search(payment_segment) + if not invoice_match: + raise ValueError(f"Invalid MPP challenge: {header[:80]}") + + invoice = invoice_match.group("invoice").strip() + amount_match = _MPP_AMOUNT_RE.search(payment_segment) + realm_match = _MPP_REALM_RE.search(payment_segment) + + return MppChallenge( + invoice=invoice, + amount=amount_match.group("amount").strip() if amount_match else None, + realm=realm_match.group("realm").strip() if realm_match else None, + ) + + +def parse_payment_challenge( + headers: dict[str, str], +) -> L402Challenge | MppChallenge: + """Parse WWW-Authenticate headers, trying L402 first then MPP. + + Prefers L402 when available; falls back to MPP (Machine Payments Protocol). + + Args: + headers: HTTP response headers dict. + + Returns: + Parsed L402Challenge or MppChallenge. + + Raises: + ValueError: If no valid L402 or MPP challenge is found. + """ + lower_headers = {k.lower(): v for k, v in headers.items()} + if "www-authenticate" not in lower_headers: + raise ValueError("No WWW-Authenticate header found") + www_auth = lower_headers["www-authenticate"] + if not www_auth: + raise ValueError("Empty WWW-Authenticate header") + + # Try L402 first (preferred) + l402 = parse_l402_challenge(headers) + if l402 is not None: + return l402 + + # Try MPP fallback + try: + return parse_mpp_challenge(www_auth) + except ValueError: + pass + + raise ValueError(f"No valid L402 or MPP challenge: {www_auth[:80]}") + + class L402Client: """Async HTTP client with L402 payment support. @@ -181,8 +305,11 @@ async def access( if response.status_code != 402: return response - challenge = parse_l402_challenge(dict(response.headers)) - if challenge is None: + # Try L402 first, then MPP fallback + resp_headers = dict(response.headers) + try: + challenge = parse_payment_challenge(resp_headers) + except ValueError: return response if self._pay_callback is None: @@ -219,13 +346,19 @@ async def access( f"got length {len(preimage) if isinstance(preimage, str) else 'N/A'}" ) - self._cache[challenge.macaroon] = preimage - logger.info( - "L402 payment succeeded. Preimage: %s (save this for recovery)", preimage - ) - - # Retry the request with L402 credentials, with retry+backoff - headers["Authorization"] = f"L402 {challenge.macaroon}:{preimage}" + # Build the correct Authorization header based on challenge type + if isinstance(challenge, MppChallenge): + auth_header = f'Payment method="lightning", preimage="{preimage}"' + logger.info("MPP payment succeeded for %s", url) + logger.debug("MPP preimage (first 8 chars): %.8s...", preimage) + else: + self._cache[challenge.macaroon] = preimage + auth_header = f"L402 {challenge.macaroon}:{preimage}" + logger.info("L402 payment succeeded for %s", url) + logger.debug("L402 preimage (first 8 chars): %.8s...", preimage) + + # Retry the request with credentials, with retry+backoff + headers["Authorization"] = auth_header max_retries = 3 last_exc: Optional[Exception] = None @@ -237,20 +370,20 @@ async def access( last_exc = exc logger.warning( "Authenticated retry attempt %d/%d failed: %s. " - "Preimage for recovery: %s", + "Preimage prefix for recovery: %.8s...", attempt + 1, max_retries, exc, preimage, ) if attempt < max_retries - 1: await asyncio.sleep(0.5 * (2 ** attempt)) - # All retries exhausted — log preimage for recovery + # All retries exhausted — log preimage prefix for recovery identification logger.error( "All %d authenticated retries failed after payment. " - "IMPORTANT — save this preimage for manual recovery: %s", + "Preimage prefix for recovery: %.8s... (full value never logged for security)", max_retries, preimage, ) raise RuntimeError( - f"Payment succeeded (preimage: {preimage}) but all {max_retries} " + f"Payment succeeded (preimage prefix: {preimage[:8]}...) but all {max_retries} " f"authenticated retries failed: {last_exc}" ) @@ -282,14 +415,46 @@ async def pay_and_access( if response.status_code != 402: return response - challenge = parse_l402_challenge(dict(response.headers)) - if challenge is None: + # Try L402 first, then MPP fallback + resp_headers = dict(response.headers) + try: + challenge = parse_payment_challenge(resp_headers) + except ValueError: return response - preimage = await pay_invoice_callback(challenge.invoice) - self._cache[challenge.macaroon] = preimage + try: + preimage = await pay_invoice_callback(challenge.invoice) + except Exception as exc: + logger.error( + "Error in pay_invoice_callback during pay_and_access for URL %r: %s", + url, + exc, + exc_info=True, + ) + raise RuntimeError( + "Payment callback failed during pay_and_access; see logs for details" + ) from exc + + # Validate preimage format before constructing credentials + if not self._validate_preimage(preimage): + logger.error( + "Invalid preimage returned from pay callback in pay_and_access: " + "expected 64-char hex, got %r (length=%d)", + preimage[:20] if isinstance(preimage, str) else type(preimage), + len(preimage) if isinstance(preimage, str) else 0, + ) + raise ValueError( + f"Invalid preimage from payment callback: expected 64-character hex string, " + f"got length {len(preimage) if isinstance(preimage, str) else 'N/A'}" + ) + + # Build the correct Authorization header based on challenge type + if isinstance(challenge, MppChallenge): + headers["Authorization"] = f'Payment method="lightning", preimage="{preimage}"' + else: + self._cache[challenge.macaroon] = preimage + headers["Authorization"] = f"L402 {challenge.macaroon}:{preimage}" - headers["Authorization"] = f"L402 {challenge.macaroon}:{preimage}" retry_response = await client.request(method, url, headers=headers, **kwargs) return retry_response @@ -427,27 +592,54 @@ async def create_challenge( async def verify_payment( self, - macaroon: str, - preimage: str, + macaroon: Optional[str] = None, + preimage: Optional[str] = None, ) -> L402VerifyResponse: - """Verify an L402 token (macaroon + preimage) to confirm payment. + """Verify an L402 or MPP token to confirm payment. + + For L402 verification, provide both macaroon and preimage. + For MPP verification, only the preimage is required (macaroon is None). - The provider calls this after receiving an L402 token from the requester + The provider calls this after receiving a token from the requester to validate that the invoice has been paid before delivering the service. Args: - macaroon: Base64-encoded macaroon from the L402 token. - preimage: Hex-encoded preimage (proof of payment). + macaroon: Base64-encoded macaroon from the L402 token. Optional for + MPP payments where only a preimage is provided. Pass None to use + MPP verification without a macaroon. + preimage: Hex-encoded preimage (proof of payment). Required. Returns: L402VerifyResponse indicating whether the payment is valid. + + Raises: + ValueError: If preimage is not provided, or if macaroon is provided + but empty/whitespace. """ + if not preimage or not isinstance(preimage, str) or not preimage.strip(): + raise ValueError( + "preimage is required; pass a non-empty hex-encoded preimage string" + ) + client = self._ensure_client() + payload: dict[str, str] = {"preimage": preimage.strip()} + + # Distinguish MPP (macaroon is None) from an explicitly provided but + # empty/whitespace macaroon, which should be treated as an error. + if macaroon is not None: + macaroon_stripped = macaroon.strip() + if not macaroon_stripped: + raise ValueError( + "macaroon must be a non-empty string when provided; " + "use None to request MPP verification without a macaroon." + ) + payload["macaroon"] = macaroon_stripped + try: response = await client.post( f"{self._base_url}/api/l402/challenges/verify", - json={"macaroon": macaroon.strip(), "preimage": preimage.strip()}, + json=payload, ) if response.status_code != 200: diff --git a/tests/test_l402_client.py b/tests/test_l402_client.py index ba371ce..4fb6605 100644 --- a/tests/test_l402_client.py +++ b/tests/test_l402_client.py @@ -1,8 +1,18 @@ -"""Tests for L402 client — challenge parsing and HTTP flow.""" +"""Tests for L402 client — challenge parsing, MPP support, and HTTP flow.""" import pytest +import httpx +from unittest.mock import AsyncMock, patch -from le_agent_sdk.l402.client import L402Challenge, L402Client, parse_l402_challenge +from le_agent_sdk.l402.client import ( + L402Challenge, + L402Client, + L402ProducerClient, + MppChallenge, + parse_l402_challenge, + parse_mpp_challenge, + parse_payment_challenge, +) class TestParseL402Challenge: @@ -74,3 +84,485 @@ async def test_close_idempotent(self): client = L402Client() await client.close() await client.close() # Should not raise + + +class TestL402ClientAccessFlow: + """Tests for the 402 -> pay -> retry flow in L402Client.access().""" + + @pytest.mark.asyncio + async def test_access_l402_402_pay_retry(self): + """L402 challenge: 402 response, pay invoice, retry with L402 Authorization header.""" + fake_preimage = "ab" * 32 + + # First call returns 402 with L402 challenge + response_402 = httpx.Response( + 402, + headers={"WWW-Authenticate": 'L402 macaroon="mac_test", invoice="lnbc100n1pjtest"'}, + request=httpx.Request("GET", "https://api.example.com/resource"), + ) + # Second call returns 200 (after auth) + response_200 = httpx.Response( + 200, + json={"data": "success"}, + request=httpx.Request("GET", "https://api.example.com/resource"), + ) + + pay_callback = AsyncMock(return_value=fake_preimage) + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, side_effect=[response_402, response_200]) as mock_request: + async with L402Client(pay_invoice_callback=pay_callback) as client: + result = await client.access("https://api.example.com/resource") + + assert result.status_code == 200 + pay_callback.assert_awaited_once_with("lnbc100n1pjtest") + # Verify the retry request used the correct L402 Authorization header + retry_call = mock_request.call_args_list[1] + retry_headers = retry_call.kwargs.get("headers", {}) + assert retry_headers["Authorization"] == f"L402 mac_test:{fake_preimage}" + + @pytest.mark.asyncio + async def test_access_mpp_402_pay_retry(self): + """MPP challenge: 402 response, pay invoice, retry with Payment Authorization header.""" + fake_preimage = "cd" * 32 + + # First call returns 402 with MPP challenge + response_402 = httpx.Response( + 402, + headers={"WWW-Authenticate": 'Payment method="lightning", invoice="lnbc200n1pjmpptest"'}, + request=httpx.Request("GET", "https://api.example.com/mpp-resource"), + ) + # Second call returns 200 (after auth) + response_200 = httpx.Response( + 200, + json={"data": "mpp-success"}, + request=httpx.Request("GET", "https://api.example.com/mpp-resource"), + ) + + pay_callback = AsyncMock(return_value=fake_preimage) + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, side_effect=[response_402, response_200]) as mock_request: + async with L402Client(pay_invoice_callback=pay_callback) as client: + result = await client.access("https://api.example.com/mpp-resource") + + assert result.status_code == 200 + pay_callback.assert_awaited_once_with("lnbc200n1pjmpptest") + # Verify the retry request used the correct Payment Authorization header + retry_call = mock_request.call_args_list[1] + retry_headers = retry_call.kwargs.get("headers", {}) + assert retry_headers["Authorization"] == f'Payment method="lightning", preimage="{fake_preimage}"' + + @pytest.mark.asyncio + async def test_access_no_callback_returns_402(self): + """Without a pay callback, 402 responses are returned as-is.""" + response_402 = httpx.Response( + 402, + headers={"WWW-Authenticate": 'L402 macaroon="mac_test", invoice="lnbc100n1pjtest"'}, + request=httpx.Request("GET", "https://api.example.com/resource"), + ) + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, return_value=response_402): + async with L402Client() as client: + result = await client.access("https://api.example.com/resource") + + assert result.status_code == 402 + + +class TestL402ClientPayAndAccessFlow: + """Tests for the 402 -> pay -> retry flow in L402Client.pay_and_access().""" + + @pytest.mark.asyncio + async def test_pay_and_access_l402_flow(self): + """L402: pay_and_access should pay invoice and retry with L402 Authorization.""" + fake_preimage = "ef" * 32 + + response_402 = httpx.Response( + 402, + headers={"WWW-Authenticate": 'L402 macaroon="mac_paa", invoice="lnbc300n1pjpaatest"'}, + request=httpx.Request("GET", "https://api.example.com/l402-resource"), + ) + response_200 = httpx.Response( + 200, + json={"data": "l402-paid"}, + request=httpx.Request("GET", "https://api.example.com/l402-resource"), + ) + + pay_callback = AsyncMock(return_value=fake_preimage) + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, side_effect=[response_402, response_200]) as mock_request: + async with L402Client() as client: + result = await client.pay_and_access( + "https://api.example.com/l402-resource", + pay_invoice_callback=pay_callback, + ) + + assert result.status_code == 200 + pay_callback.assert_awaited_once_with("lnbc300n1pjpaatest") + retry_call = mock_request.call_args_list[1] + retry_headers = retry_call.kwargs.get("headers", {}) + assert retry_headers["Authorization"] == f"L402 mac_paa:{fake_preimage}" + + @pytest.mark.asyncio + async def test_pay_and_access_mpp_flow(self): + """MPP: pay_and_access should pay invoice and retry with Payment Authorization.""" + fake_preimage = "01" * 32 + + response_402 = httpx.Response( + 402, + headers={"WWW-Authenticate": 'Payment method="lightning", invoice="lnbc400n1pjmpppaa"'}, + request=httpx.Request("GET", "https://api.example.com/mpp-resource"), + ) + response_200 = httpx.Response( + 200, + json={"data": "mpp-paid"}, + request=httpx.Request("GET", "https://api.example.com/mpp-resource"), + ) + + pay_callback = AsyncMock(return_value=fake_preimage) + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, side_effect=[response_402, response_200]) as mock_request: + async with L402Client() as client: + result = await client.pay_and_access( + "https://api.example.com/mpp-resource", + pay_invoice_callback=pay_callback, + ) + + assert result.status_code == 200 + pay_callback.assert_awaited_once_with("lnbc400n1pjmpppaa") + retry_call = mock_request.call_args_list[1] + retry_headers = retry_call.kwargs.get("headers", {}) + assert retry_headers["Authorization"] == f'Payment method="lightning", preimage="{fake_preimage}"' + + @pytest.mark.asyncio + async def test_pay_and_access_callback_failure_raises(self): + """Payment callback failure should raise RuntimeError with context.""" + response_402 = httpx.Response( + 402, + headers={"WWW-Authenticate": 'L402 macaroon="mac_fail", invoice="lnbc100n1pjfail"'}, + request=httpx.Request("GET", "https://api.example.com/resource"), + ) + + pay_callback = AsyncMock(side_effect=ConnectionError("wallet offline")) + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, return_value=response_402): + async with L402Client() as client: + with pytest.raises(RuntimeError, match="Payment callback failed"): + await client.pay_and_access( + "https://api.example.com/resource", + pay_invoice_callback=pay_callback, + ) + + @pytest.mark.asyncio + async def test_pay_and_access_non_402_returns_directly(self): + """Non-402 responses should be returned without attempting payment.""" + response_200 = httpx.Response( + 200, + json={"data": "free"}, + request=httpx.Request("GET", "https://api.example.com/free"), + ) + + pay_callback = AsyncMock() + + with patch("httpx.AsyncClient.request", new_callable=AsyncMock, return_value=response_200): + async with L402Client() as client: + result = await client.pay_and_access( + "https://api.example.com/free", + pay_invoice_callback=pay_callback, + ) + + assert result.status_code == 200 + pay_callback.assert_not_awaited() + + +class TestMppRegexBoundary: + """Tests for MPP regex parameter-boundary matching.""" + + def test_no_false_positive_on_substring_method(self): + """A parameter like some_method='lightning' should not be matched as method='lightning'.""" + header = 'Payment some_method="lightning", invoice="lnbc100n1pjtest", method="lightning"' + result = parse_mpp_challenge(header) + # Should still parse correctly because real method="lightning" is present + assert result.invoice == "lnbc100n1pjtest" + + def test_reject_only_prefixed_method(self): + """If only a prefixed 'method' exists (no real 'method'), it should fail.""" + header = 'Payment xmethod="lightning", invoice="lnbc100n1pjtest"' + with pytest.raises(ValueError): + parse_mpp_challenge(header) + + def test_reject_lightning_suffix_in_method(self): + """method='lightning2' or method='lightningXYZ' must not be accepted.""" + header = 'Payment method="lightning2", invoice="lnbc100n1pjtest"' + with pytest.raises(ValueError): + parse_mpp_challenge(header) + + def test_reject_unquoted_lightning_suffix(self): + """Bare token method=lightningXYZ must not be accepted.""" + header = "Payment method=lightningXYZ, invoice=lnbc100n1pjtest" + with pytest.raises(ValueError): + parse_mpp_challenge(header) + + +class TestMppChallengeParsing: + def test_parse_valid_mpp_header(self): + header = 'Payment realm="api.example.com", method="lightning", invoice="lnbc100n1pjtest", amount="100", currency="sat"' + result = parse_mpp_challenge(header) + assert isinstance(result, MppChallenge) + assert result.invoice == "lnbc100n1pjtest" + assert result.amount == "100" + assert result.realm == "api.example.com" + + def test_parse_non_lightning_raises(self): + with pytest.raises(ValueError): + parse_mpp_challenge('Payment method="stripe", invoice="lnbc100n1pjtest"') + + def test_parse_missing_invoice_raises(self): + with pytest.raises(ValueError): + parse_mpp_challenge('Payment method="lightning", amount="100"') + + def test_parse_minimal_header(self): + result = parse_mpp_challenge( + 'Payment method="lightning", invoice="lnbc100n1pjtest"' + ) + assert result.invoice == "lnbc100n1pjtest" + assert result.amount is None + assert result.realm is None + + def test_parse_case_insensitive(self): + header = 'PAYMENT METHOD="LIGHTNING", INVOICE="lnbc100n1pjtest", AMOUNT="50"' + result = parse_mpp_challenge(header) + assert result.invoice == "lnbc100n1pjtest" + assert result.amount == "50" + + def test_mpp_challenge_frozen(self): + c = MppChallenge(invoice="inv1", amount="100", realm="example.com") + with pytest.raises(AttributeError): + c.invoice = "changed" + + def test_realm_scoped_to_payment_segment(self): + """Realm from a different scheme (Bearer) must not leak into MPP.""" + header = ( + 'Bearer realm="other-service.com", ' + 'Payment method="lightning", invoice="lnbc100n1pjtest"' + ) + result = parse_mpp_challenge(header) + assert result.invoice == "lnbc100n1pjtest" + # The Bearer realm must NOT be captured + assert result.realm is None + + def test_parse_unquoted_values(self): + """Bare token (unquoted) auth-param values should parse correctly.""" + header = 'Payment method=lightning, invoice=lnbc100n1pjtest, amount=100, realm=api.example.com' + result = parse_mpp_challenge(header) + assert result.invoice == "lnbc100n1pjtest" + assert result.amount == "100" + assert result.realm == "api.example.com" + + def test_realm_scoped_with_trailing_scheme(self): + """Realm from a trailing scheme must not leak into a Payment challenge.""" + header = ( + 'Payment method="lightning", invoice="lnbc100n1pjtest", ' + 'Bearer realm="other-service.com"' + ) + result = parse_mpp_challenge(header) + assert result.invoice == "lnbc100n1pjtest" + # The trailing Bearer realm must NOT be captured + assert result.realm is None + + +class TestParsePaymentChallenge: + def test_l402_preferred(self): + headers = { + "WWW-Authenticate": 'L402 macaroon="abc", invoice="lnbc100n1pjtest"' + } + result = parse_payment_challenge(headers) + assert isinstance(result, L402Challenge) + assert result.macaroon == "abc" + assert result.invoice == "lnbc100n1pjtest" + + def test_mpp_fallback(self): + headers = { + "WWW-Authenticate": 'Payment method="lightning", invoice="lnbc100n1pjtest"' + } + result = parse_payment_challenge(headers) + assert isinstance(result, MppChallenge) + assert result.invoice == "lnbc100n1pjtest" + + def test_invalid_raises(self): + headers = {"WWW-Authenticate": "Bearer token123"} + with pytest.raises(ValueError): + parse_payment_challenge(headers) + + def test_no_header_raises(self): + headers = {"Content-Type": "application/json"} + with pytest.raises(ValueError): + parse_payment_challenge(headers) + + def test_empty_header_raises(self): + headers = {"WWW-Authenticate": ""} + with pytest.raises(ValueError, match="Empty WWW-Authenticate header"): + parse_payment_challenge(headers) + + def test_l402_with_both_present(self): + """When both L402 and MPP headers exist (combined), L402 is preferred.""" + headers = { + "WWW-Authenticate": ( + 'L402 macaroon="mac1", invoice="lnbc100n1pjl402" ' + 'Payment method="lightning", invoice="lnbc100n1pjmpp"' + ) + } + result = parse_payment_challenge(headers) + assert isinstance(result, L402Challenge) + assert result.macaroon == "mac1" + + +class TestL402ProducerClientVerifyPayment: + """Tests for L402ProducerClient.verify_payment() covering both L402 and MPP flows.""" + + @pytest.mark.asyncio + async def test_verify_with_macaroon_sends_both_fields(self): + """When macaroon is provided, payload should include both macaroon and preimage.""" + mock_response = httpx.Response( + 200, + json={"valid": True, "resource": "/api/data"}, + request=httpx.Request("POST", "https://api.lightningenable.com/api/l402/challenges/verify"), + ) + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post: + async with L402ProducerClient(le_api_key="test-key") as client: + result = await client.verify_payment("mac123", preimage="aa" * 32) + + assert result.success is True + assert result.valid is True + assert result.resource == "/api/data" + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs["json"] + assert payload["macaroon"] == "mac123" + assert payload["preimage"] == "aa" * 32 + + @pytest.mark.asyncio + async def test_verify_without_macaroon_sends_preimage_only(self): + """MPP flow: when macaroon is omitted, payload should only contain preimage.""" + mock_response = httpx.Response( + 200, + json={"valid": True, "resource": "/api/mpp-data"}, + request=httpx.Request("POST", "https://api.lightningenable.com/api/l402/challenges/verify"), + ) + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post: + async with L402ProducerClient(le_api_key="test-key") as client: + result = await client.verify_payment(preimage="bb" * 32) + + assert result.success is True + assert result.valid is True + assert result.resource == "/api/mpp-data" + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs["json"] + assert "macaroon" not in payload + assert payload["preimage"] == "bb" * 32 + + @pytest.mark.asyncio + async def test_verify_without_macaroon_none_explicit(self): + """Explicitly passing macaroon=None should omit it from payload.""" + mock_response = httpx.Response( + 200, + json={"valid": False}, + request=httpx.Request("POST", "https://api.lightningenable.com/api/l402/challenges/verify"), + ) + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post: + async with L402ProducerClient(le_api_key="test-key") as client: + result = await client.verify_payment(None, preimage="cc" * 32) + + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs["json"] + assert "macaroon" not in payload + + @pytest.mark.asyncio + async def test_verify_missing_preimage_raises_value_error(self): + """Calling verify_payment() without preimage should raise ValueError.""" + async with L402ProducerClient(le_api_key="test-key") as client: + with pytest.raises(ValueError, match="preimage is required"): + await client.verify_payment("mac123") + + @pytest.mark.asyncio + async def test_verify_no_args_raises_value_error(self): + """Calling verify_payment() with no arguments should raise ValueError.""" + async with L402ProducerClient(le_api_key="test-key") as client: + with pytest.raises(ValueError, match="preimage is required"): + await client.verify_payment() + + @pytest.mark.asyncio + async def test_verify_empty_preimage_raises_value_error(self): + """Passing an empty string as preimage should raise ValueError.""" + async with L402ProducerClient(le_api_key="test-key") as client: + with pytest.raises(ValueError, match="preimage is required"): + await client.verify_payment("mac123", preimage="") + + @pytest.mark.asyncio + async def test_verify_whitespace_preimage_raises_value_error(self): + """Passing whitespace-only preimage should raise ValueError.""" + async with L402ProducerClient(le_api_key="test-key") as client: + with pytest.raises(ValueError, match="preimage is required"): + await client.verify_payment("mac123", preimage=" ") + + @pytest.mark.asyncio + async def test_verify_empty_macaroon_raises_value_error(self): + """Passing an empty string as macaroon should raise ValueError, not silently switch to MPP.""" + async with L402ProducerClient(le_api_key="test-key") as client: + with pytest.raises(ValueError, match="macaroon must be a non-empty string"): + await client.verify_payment("", preimage="aa" * 32) + + @pytest.mark.asyncio + async def test_verify_whitespace_macaroon_raises_value_error(self): + """Passing whitespace-only macaroon should raise ValueError.""" + async with L402ProducerClient(le_api_key="test-key") as client: + with pytest.raises(ValueError, match="macaroon must be a non-empty string"): + await client.verify_payment(" ", preimage="aa" * 32) + + @pytest.mark.asyncio + async def test_verify_positional_backward_compat(self): + """Calling verify_payment(macaroon, preimage) positionally should still work.""" + mock_response = httpx.Response( + 200, + json={"valid": True, "resource": "/api/data"}, + request=httpx.Request("POST", "https://api.lightningenable.com/api/l402/challenges/verify"), + ) + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post: + async with L402ProducerClient(le_api_key="test-key") as client: + result = await client.verify_payment("mac123", "aa" * 32) + + assert result.success is True + assert result.valid is True + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs["json"] + assert payload["macaroon"] == "mac123" + assert payload["preimage"] == "aa" * 32 + + @pytest.mark.asyncio + async def test_verify_api_error_returns_failure(self): + """Non-200 response should return a failure result.""" + mock_response = httpx.Response( + 401, + json={"error": "Invalid API key"}, + request=httpx.Request("POST", "https://api.lightningenable.com/api/l402/challenges/verify"), + ) + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response): + async with L402ProducerClient(le_api_key="bad-key") as client: + result = await client.verify_payment(preimage="dd" * 32) + + assert result.success is False + assert "Invalid API key" in result.error + + @pytest.mark.asyncio + async def test_verify_strips_whitespace(self): + """Macaroon and preimage values should be stripped of whitespace.""" + mock_response = httpx.Response( + 200, + json={"valid": True}, + request=httpx.Request("POST", "https://api.lightningenable.com/api/l402/challenges/verify"), + ) + with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post: + async with L402ProducerClient(le_api_key="test-key") as client: + result = await client.verify_payment(" mac123 ", preimage=" " + "ee" * 32 + " ") + + call_kwargs = mock_post.call_args + payload = call_kwargs.kwargs["json"] + assert payload["macaroon"] == "mac123" + assert payload["preimage"] == "ee" * 32