mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-07 02:04:36 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a60b04aea0 | ||
|
|
667f9bf6bb |
+6
-46
@@ -1,53 +1,13 @@
|
||||
# Git and repository metadata
|
||||
.env
|
||||
.venv
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
compose.yml
|
||||
compose.testing.yml
|
||||
.todo
|
||||
.github
|
||||
.vscode
|
||||
.DS_Store
|
||||
.todo
|
||||
|
||||
# Local configuration and secrets
|
||||
**/.env
|
||||
**/.env.*
|
||||
**/routstr_secret.key
|
||||
|
||||
# Python environments, caches, and build artifacts
|
||||
**/.venv
|
||||
**/__pycache__
|
||||
**/*.py[cod]
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
**/.ruff_cache
|
||||
**/.coverage
|
||||
**/htmlcov
|
||||
**/*.egg-info
|
||||
**/build
|
||||
**/dist
|
||||
|
||||
# Runtime state must never be baked into the image
|
||||
logs
|
||||
logs.*
|
||||
**/*.log
|
||||
**/.wallet*
|
||||
**/.cashu
|
||||
**/*.db
|
||||
**/*.db-*
|
||||
**/*.sqlite3
|
||||
**/*.sqlite3-*
|
||||
**/keys
|
||||
**/proof_backups
|
||||
**/relay-data
|
||||
|
||||
# The UI output is built by its own service and mounted at runtime
|
||||
ui_out
|
||||
ui/.next
|
||||
ui/out
|
||||
**/node_modules
|
||||
|
||||
# Compose and local development files
|
||||
compose.yml
|
||||
compose.testing.yml
|
||||
compose.override.yml
|
||||
plans
|
||||
.worktrees
|
||||
ui/.next
|
||||
|
||||
+10
-13
@@ -613,19 +613,16 @@ async def refund_wallet_endpoint(
|
||||
await _refund_cache_set(bearer_value, result)
|
||||
|
||||
if "token" in result:
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=result["token"],
|
||||
amount=remaining_balance,
|
||||
unit=key.refund_currency or "sat",
|
||||
mint_url=effective_refund_mint,
|
||||
typ="out",
|
||||
collected=False,
|
||||
source="apikey",
|
||||
api_key_hashed_key=key.hashed_key,
|
||||
)
|
||||
except Exception:
|
||||
pass # store_cashu_transaction already logs
|
||||
await store_cashu_transaction(
|
||||
token=result["token"],
|
||||
amount=remaining_balance,
|
||||
unit=key.refund_currency or "sat",
|
||||
mint_url=effective_refund_mint,
|
||||
typ="out",
|
||||
collected=False,
|
||||
source="apikey",
|
||||
api_key_hashed_key=key.hashed_key,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"refund_wallet_endpoint: refund successful",
|
||||
|
||||
+9
-19
@@ -455,25 +455,15 @@ async def withdraw(
|
||||
status_code=400, detail="Insufficient wallet balance"
|
||||
) from error
|
||||
actual_mint = token_mint_url(token, effective_mint)
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=token,
|
||||
amount=withdraw_request.amount,
|
||||
unit=withdraw_request.unit,
|
||||
mint_url=actual_mint,
|
||||
typ="out",
|
||||
collected=False,
|
||||
source="admin",
|
||||
)
|
||||
except Exception:
|
||||
logger.critical(
|
||||
"Admin withdrawal token issued without a persisted audit record",
|
||||
extra={
|
||||
"amount": withdraw_request.amount,
|
||||
"unit": withdraw_request.unit,
|
||||
"mint_url": actual_mint,
|
||||
},
|
||||
)
|
||||
await store_cashu_transaction(
|
||||
token=token,
|
||||
amount=withdraw_request.amount,
|
||||
unit=withdraw_request.unit,
|
||||
mint_url=actual_mint,
|
||||
typ="out",
|
||||
collected=False,
|
||||
source="admin",
|
||||
)
|
||||
return {"token": token, "mint_url": actual_mint}
|
||||
|
||||
|
||||
|
||||
+67
-81
@@ -3594,37 +3594,12 @@ class BaseUpstreamProvider:
|
||||
|
||||
max_retries = 3
|
||||
last_exception = None
|
||||
refund_token = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
refund_token = await send_token(amount, unit=unit, mint_url=mint)
|
||||
|
||||
logger.info(
|
||||
"Refund token created successfully",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"mint": mint,
|
||||
"attempt": attempt + 1,
|
||||
"token_preview": refund_token[:20] + "..."
|
||||
if len(refund_token) > 20
|
||||
else refund_token,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=refund_token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=token_mint_url(refund_token, mint),
|
||||
typ="out",
|
||||
request_id=request_id,
|
||||
)
|
||||
except Exception:
|
||||
pass # store_cashu_transaction already logs
|
||||
|
||||
return refund_token
|
||||
break
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries - 1:
|
||||
@@ -3654,16 +3629,39 @@ class BaseUpstreamProvider:
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}",
|
||||
"type": "invalid_request_error",
|
||||
"code": "send_token_failed",
|
||||
}
|
||||
if refund_token is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}",
|
||||
"type": "invalid_request_error",
|
||||
"code": "send_token_failed",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Refund token created successfully",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"mint": mint,
|
||||
"attempt": attempt + 1,
|
||||
"token_preview": refund_token[:20] + "..."
|
||||
if len(refund_token) > 20
|
||||
else refund_token,
|
||||
},
|
||||
)
|
||||
await store_cashu_transaction(
|
||||
token=refund_token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=token_mint_url(refund_token, mint),
|
||||
typ="out",
|
||||
request_id=request_id,
|
||||
)
|
||||
return refund_token
|
||||
|
||||
async def handle_x_cashu_streaming_response(
|
||||
self,
|
||||
@@ -3979,17 +3977,14 @@ class BaseUpstreamProvider:
|
||||
emergency_refund = amount
|
||||
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
|
||||
response.headers["X-Cashu"] = refund_token
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=refund_token,
|
||||
amount=emergency_refund,
|
||||
unit=unit,
|
||||
mint_url=token_mint_url(refund_token, mint),
|
||||
typ="out",
|
||||
request_id=request_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await store_cashu_transaction(
|
||||
token=refund_token,
|
||||
amount=emergency_refund,
|
||||
unit=unit,
|
||||
mint_url=token_mint_url(refund_token, mint),
|
||||
typ="out",
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Emergency refund issued due to JSON parse error",
|
||||
@@ -4345,18 +4340,15 @@ class BaseUpstreamProvider:
|
||||
headers = self.prepare_headers(dict(request.headers))
|
||||
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=x_cashu_token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint,
|
||||
typ="in",
|
||||
request_id=request_id,
|
||||
collected=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await store_cashu_transaction(
|
||||
token=x_cashu_token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint,
|
||||
typ="in",
|
||||
request_id=request_id,
|
||||
collected=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"X-Cashu token redeemed for Responses API",
|
||||
@@ -4960,17 +4952,14 @@ class BaseUpstreamProvider:
|
||||
emergency_refund = amount
|
||||
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
|
||||
response.headers["X-Cashu"] = refund_token
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=refund_token,
|
||||
amount=emergency_refund,
|
||||
unit=unit,
|
||||
mint_url=token_mint_url(refund_token, mint),
|
||||
typ="out",
|
||||
request_id=request_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await store_cashu_transaction(
|
||||
token=refund_token,
|
||||
amount=emergency_refund,
|
||||
unit=unit,
|
||||
mint_url=token_mint_url(refund_token, mint),
|
||||
typ="out",
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Emergency refund issued for Responses API due to JSON parse error",
|
||||
@@ -5034,18 +5023,15 @@ class BaseUpstreamProvider:
|
||||
headers = self.prepare_headers(dict(request.headers))
|
||||
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=x_cashu_token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint,
|
||||
typ="in",
|
||||
request_id=request_id,
|
||||
collected=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await store_cashu_transaction(
|
||||
token=x_cashu_token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint,
|
||||
typ="in",
|
||||
request_id=request_id,
|
||||
collected=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"X-Cashu token redeemed successfully",
|
||||
|
||||
+17
-23
@@ -749,17 +749,14 @@ async def send_cashu_refund(
|
||||
) -> str:
|
||||
"""Create a Cashu refund token and record the outgoing transaction."""
|
||||
refund_token = await send_token(amount, unit=unit, mint_url=mint)
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=refund_token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint,
|
||||
typ="out",
|
||||
request_id=request_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await store_cashu_transaction(
|
||||
token=refund_token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint,
|
||||
typ="out",
|
||||
request_id=request_id,
|
||||
)
|
||||
return refund_token
|
||||
|
||||
|
||||
@@ -1014,18 +1011,15 @@ async def forward_ehbp_x_cashu_request(
|
||||
try:
|
||||
amount, unit, mint = await recieve_token(x_cashu_token)
|
||||
redeemed = True
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=x_cashu_token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint,
|
||||
typ="in",
|
||||
request_id=request_id,
|
||||
collected=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await store_cashu_transaction(
|
||||
token=x_cashu_token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint,
|
||||
typ="in",
|
||||
request_id=request_id,
|
||||
collected=True,
|
||||
)
|
||||
|
||||
headers = upstream.prepare_headers(dict(request.headers)) # type: ignore[attr-defined]
|
||||
target = upstream.get_ehbp_forwarding_target(path, model_obj) # type: ignore[attr-defined]
|
||||
|
||||
+13
-17
@@ -1703,23 +1703,19 @@ async def _credit_balance_locked(
|
||||
extra={"new_balance": key.balance},
|
||||
)
|
||||
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=cashu_token,
|
||||
amount=original_amount,
|
||||
unit=original_unit,
|
||||
mint_url=mint_url,
|
||||
typ="in",
|
||||
source="apikey",
|
||||
api_key_hashed_key=key.hashed_key,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
logger.debug(
|
||||
"Cashu token successfully redeemed and stored",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
)
|
||||
await store_cashu_transaction(
|
||||
token=cashu_token,
|
||||
amount=original_amount,
|
||||
unit=original_unit,
|
||||
mint_url=mint_url,
|
||||
typ="in",
|
||||
source="apikey",
|
||||
api_key_hashed_key=key.hashed_key,
|
||||
)
|
||||
logger.debug(
|
||||
"Cashu token successfully redeemed and stored",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
)
|
||||
return amount
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
||||
@@ -45,7 +45,7 @@ async def test_withdraw_uses_effective_mint_and_records_outgoing_transaction(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_withdraw_returns_issued_token_when_audit_storage_fails(
|
||||
async def test_withdraw_propagates_audit_storage_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
mint = "https://primary.example"
|
||||
@@ -58,14 +58,10 @@ async def test_withdraw_returns_issued_token_when_audit_storage_fails(
|
||||
"store_cashu_transaction",
|
||||
AsyncMock(side_effect=RuntimeError("database unavailable")),
|
||||
)
|
||||
critical = Mock()
|
||||
monkeypatch.setattr(admin.logger, "critical", critical)
|
||||
monkeypatch.setattr(admin.settings, "primary_mint", mint)
|
||||
|
||||
result = await admin.withdraw(Mock(), admin.WithdrawRequest(amount=75))
|
||||
|
||||
assert result == {"token": token, "mint_url": mint}
|
||||
critical.assert_called_once()
|
||||
with pytest.raises(RuntimeError, match="database unavailable"):
|
||||
await admin.withdraw(Mock(), admin.WithdrawRequest(amount=75))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_backend_docker_context_excludes_generated_and_runtime_state() -> None:
|
||||
patterns = {
|
||||
line.strip()
|
||||
for line in (REPO_ROOT / ".dockerignore").read_text().splitlines()
|
||||
if line.strip() and not line.lstrip().startswith("#")
|
||||
}
|
||||
|
||||
required_patterns = {
|
||||
"**/.env",
|
||||
"**/.env.*",
|
||||
"**/routstr_secret.key",
|
||||
"**/.venv",
|
||||
"**/__pycache__",
|
||||
"**/*.py[cod]",
|
||||
"**/.pytest_cache",
|
||||
"**/.mypy_cache",
|
||||
"**/.ruff_cache",
|
||||
"**/.coverage",
|
||||
"**/htmlcov",
|
||||
"**/*.egg-info",
|
||||
"**/build",
|
||||
"**/dist",
|
||||
"logs",
|
||||
"logs.*",
|
||||
"**/*.log",
|
||||
"**/.wallet*",
|
||||
"**/.cashu",
|
||||
"**/*.db",
|
||||
"**/*.db-*",
|
||||
"**/*.sqlite3",
|
||||
"**/*.sqlite3-*",
|
||||
"**/keys",
|
||||
"**/proof_backups",
|
||||
"**/relay-data",
|
||||
"ui_out",
|
||||
"ui/.next",
|
||||
"ui/out",
|
||||
}
|
||||
|
||||
assert required_patterns <= patterns, (
|
||||
"The backend Docker context must exclude local build artifacts and "
|
||||
f"runtime state; missing patterns: {sorted(required_patterns - patterns)}"
|
||||
)
|
||||
@@ -0,0 +1,342 @@
|
||||
"""RED tests for live money-loss vulnerabilities in routstr-core.
|
||||
|
||||
Every test in this file asserts the CORRECT (safe) behaviour for a money
|
||||
path where a user, provider, or node runner can lose funds. Each test
|
||||
FAILS against current ``main`` because the code is buggy — they are the
|
||||
"RED" phase of TDD. Once the underlying bugs are fixed, they go green.
|
||||
|
||||
== Vulnerability summary (all LIVE on main as of 2026-08-06) ==
|
||||
|
||||
V-E1 send_refund() swallows DB failure after minting a refund token
|
||||
base.py ~line 3625 — except Exception: pass
|
||||
Impact: refund token is minted at the Cashu mint but never recorded
|
||||
in the DB. The user receives the token in the X-Cashu header, but
|
||||
if they lose it (or it never arrives) the refund endpoint cannot
|
||||
look it up → permanently unrecoverable funds.
|
||||
|
||||
V-E2 Emergency refund (chat) — same except: pass, false-green in the
|
||||
existing test_emergency_refund_no_try_except_pass because the
|
||||
500-char inspection window is too short to reach the except block.
|
||||
base.py ~line 3992
|
||||
|
||||
V-E3 Emergency refund (responses API) — identical pattern.
|
||||
base.py ~line 4972
|
||||
|
||||
V-E4 Balance refund endpoint swallows DB failure.
|
||||
balance.py ~line 628 — except Exception: pass
|
||||
|
||||
V-E5 credit_balance() swallows "in" transaction DB failure.
|
||||
wallet.py ~line 1715 — except Exception: pass
|
||||
Impact: token is redeemed and balance credited, but no "in" audit
|
||||
row is stored. The refund endpoint matches "in" → "out" by
|
||||
request_id; a missing "in" row breaks that linkage.
|
||||
|
||||
V-E6 EHBP refund token — except: pass after store.
|
||||
ehbp.py ~line 762
|
||||
|
||||
V-E7 EHBP "in" transaction — except: pass after store.
|
||||
ehbp.py ~line 1028
|
||||
|
||||
V-E8 Admin withdraw returns the token to the caller even when the DB
|
||||
store fails. The token is delivered but there is no audit trail.
|
||||
admin.py ~line 475
|
||||
|
||||
V-E9 Existing emergency-refund tests use a 500-char source window that
|
||||
is too short to reach the except: pass block, producing a false
|
||||
green. This test verifies the window is wide enough.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _source_contains_except_pass(source: str, anchor: str, window: int = 1000) -> bool:
|
||||
"""Return True if an ``except Exception: pass`` (or bare ``except: pass``)
|
||||
appears within ``window`` characters after ``anchor`` in ``source``."""
|
||||
idx = source.find(anchor)
|
||||
if idx < 0:
|
||||
return False
|
||||
section = source[idx : idx + window]
|
||||
pattern = r"except(?:\s+Exception)?(?:\s+as\s+\w+)?\s*:\s*pass\b"
|
||||
return re.search(pattern, section) is not None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V-E1: send_refund() must not silently swallow DB write failure
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_send_refund_no_except_pass_after_store() -> None:
|
||||
"""FIX REQUIRED: send_refund() mints a refund token then uses
|
||||
try/except/pass around store_cashu_transaction.
|
||||
|
||||
If the DB write fails the token exists at the mint but is never
|
||||
recorded. The refund endpoint cannot find it and the user's funds
|
||||
are permanently lost.
|
||||
|
||||
Correct behaviour: let the exception propagate, or at minimum log
|
||||
at CRITICAL with the full token string so an operator can manually
|
||||
recover it. Never silently pass.
|
||||
"""
|
||||
from routstr.upstream.base import BaseUpstreamProvider
|
||||
|
||||
src = inspect.getsource(BaseUpstreamProvider.send_refund)
|
||||
assert not _source_contains_except_pass(
|
||||
src, "store_cashu_transaction"
|
||||
), (
|
||||
"FIX REQUIRED: send_refund() uses try/except/pass around "
|
||||
"store_cashu_transaction (base.py ~line 3625). A failed DB write "
|
||||
"after the token is minted permanently loses the refund token. "
|
||||
"Fix: propagate the exception or log CRITICAL with the full token."
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V-E2: Emergency refund (chat) — except: pass is LIVE (existing test is
|
||||
# a false green because its 500-char window is too short)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_emergency_refund_chat_no_silent_db_failure() -> None:
|
||||
"""FIX REQUIRED: The chat emergency refund path (JSON parse error)
|
||||
uses try/except/pass around store_cashu_transaction after minting a
|
||||
refund token via send_token().
|
||||
|
||||
The existing test_emergency_refund_no_try_except_pass passes because
|
||||
it only inspects a 500-char window — too short to reach the except
|
||||
block. This test uses a wider window and correctly fails.
|
||||
"""
|
||||
from routstr.upstream.base import BaseUpstreamProvider
|
||||
|
||||
src = inspect.getsource(
|
||||
BaseUpstreamProvider.handle_x_cashu_non_streaming_response
|
||||
)
|
||||
assert not _source_contains_except_pass(
|
||||
src, "emergency_refund = amount", window=1000
|
||||
), (
|
||||
"FIX REQUIRED: Emergency refund (chat, base.py ~line 3992) uses "
|
||||
"try/except/pass around store_cashu_transaction after minting a "
|
||||
"refund token. A failed DB write permanently loses the token. "
|
||||
"Fix: propagate the exception or log CRITICAL with the full token."
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V-E3: Emergency refund (responses API) — identical pattern
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_emergency_refund_responses_no_silent_db_failure() -> None:
|
||||
"""FIX REQUIRED: Same except: pass pattern in the Responses API
|
||||
emergency refund path (base.py ~line 4972)."""
|
||||
from routstr.upstream.base import BaseUpstreamProvider
|
||||
|
||||
src = inspect.getsource(
|
||||
BaseUpstreamProvider.handle_x_cashu_non_streaming_responses_response
|
||||
)
|
||||
assert not _source_contains_except_pass(
|
||||
src, "emergency_refund = amount", window=1000
|
||||
), (
|
||||
"FIX REQUIRED: Emergency refund (responses API, base.py ~line 4972) "
|
||||
"uses try/except/pass around store_cashu_transaction after minting "
|
||||
"a refund token. Same fund-loss vulnerability as the chat path."
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V-E4: Balance refund endpoint — except: pass
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_balance_refund_endpoint_no_silent_db_failure() -> None:
|
||||
"""FIX REQUIRED: The /v1/wallet/refund endpoint mints a refund token
|
||||
via send_token() then uses try/except/pass around
|
||||
store_cashu_transaction (balance.py ~line 628).
|
||||
|
||||
A failed DB write means the token is delivered to the user but never
|
||||
recorded — the refund sweep cannot reclaim it and the audit trail
|
||||
is broken.
|
||||
"""
|
||||
from routstr import balance
|
||||
|
||||
src = inspect.getsource(balance.refund_wallet_endpoint)
|
||||
assert not _source_contains_except_pass(
|
||||
src, "store_cashu_transaction"
|
||||
), (
|
||||
"FIX REQUIRED: refund_wallet_endpoint (balance.py ~line 628) uses "
|
||||
"try/except/pass around store_cashu_transaction. A failed DB write "
|
||||
"loses the audit record for the minted refund token."
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V-E5: credit_balance() — "in" transaction except: pass
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_credit_balance_no_silent_db_failure_for_in_tx() -> None:
|
||||
"""FIX REQUIRED: credit_balance() redeems a Cashu token and credits
|
||||
the user's balance, then uses try/except/pass around
|
||||
store_cashu_transaction for the "in" record (wallet.py ~line 1715).
|
||||
|
||||
The token is already spent at the mint. If the "in" DB record is
|
||||
not stored, the refund endpoint cannot match "in" → "out" by
|
||||
request_id. The funds are credited but the audit/reconciliation
|
||||
chain is broken.
|
||||
"""
|
||||
from routstr import wallet
|
||||
|
||||
# credit_balance delegates to _credit_balance_locked
|
||||
src = inspect.getsource(wallet._credit_balance_locked)
|
||||
assert not _source_contains_except_pass(
|
||||
src, "store_cashu_transaction"
|
||||
), (
|
||||
"FIX REQUIRED: _credit_balance_locked (wallet.py ~line 1715) uses "
|
||||
"try/except/pass around store_cashu_transaction for the 'in' "
|
||||
"record. A failed DB write breaks the in→out refund linkage."
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V-E6: EHBP refund token — except: pass
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_ehbp_refund_no_silent_db_failure() -> None:
|
||||
"""FIX REQUIRED: The EHBP refund helper mints a refund token via
|
||||
send_token() then uses try/except/pass around
|
||||
store_cashu_transaction (ehbp.py ~line 762)."""
|
||||
from routstr.upstream import ehbp
|
||||
|
||||
# Find the function that creates a refund token
|
||||
refund_fn = None
|
||||
for name in dir(ehbp):
|
||||
obj = getattr(ehbp, name)
|
||||
if inspect.iscoroutinefunction(obj) and hasattr(obj, "__code__"):
|
||||
try:
|
||||
src = inspect.getsource(obj)
|
||||
if "send_token" in src and "store_cashu_transaction" in src and "typ=\"out\"" in src:
|
||||
refund_fn = obj
|
||||
break
|
||||
except (OSError, TypeError):
|
||||
continue
|
||||
|
||||
assert refund_fn is not None, "Could not locate EHBP refund function"
|
||||
src = inspect.getsource(refund_fn)
|
||||
assert not _source_contains_except_pass(
|
||||
src, "store_cashu_transaction"
|
||||
), (
|
||||
"FIX REQUIRED: EHBP refund helper (ehbp.py ~line 762) uses "
|
||||
"try/except/pass around store_cashu_transaction after minting a "
|
||||
"refund token. Same fund-loss vulnerability as base.py paths."
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V-E7: EHBP "in" transaction — except: pass
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_ehbp_in_transaction_no_silent_db_failure() -> None:
|
||||
"""FIX REQUIRED: The EHBP receive path redeems a token then uses
|
||||
try/except/pass around store_cashu_transaction for the "in" record
|
||||
(ehbp.py ~line 1028)."""
|
||||
from routstr.upstream import ehbp
|
||||
|
||||
receive_fn = None
|
||||
for name in dir(ehbp):
|
||||
obj = getattr(ehbp, name)
|
||||
if inspect.iscoroutinefunction(obj) and hasattr(obj, "__code__"):
|
||||
try:
|
||||
src = inspect.getsource(obj)
|
||||
if "recieve_token" in src and "store_cashu_transaction" in src and "typ=\"in\"" in src:
|
||||
receive_fn = obj
|
||||
break
|
||||
except (OSError, TypeError):
|
||||
continue
|
||||
|
||||
assert receive_fn is not None, "Could not locate EHBP receive function"
|
||||
src = inspect.getsource(receive_fn)
|
||||
assert not _source_contains_except_pass(
|
||||
src, "store_cashu_transaction"
|
||||
), (
|
||||
"FIX REQUIRED: EHBP receive path (ehbp.py ~line 1028) uses "
|
||||
"try/except/pass around store_cashu_transaction for the 'in' "
|
||||
"record. A failed DB write breaks the audit trail."
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V-E8: Admin withdraw must not return token when DB store fails
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_admin_withdraw_must_not_return_token_on_db_failure() -> None:
|
||||
"""FIX REQUIRED: The admin withdraw endpoint mints a token via
|
||||
send_token(), then tries to store it. If the store fails it logs
|
||||
CRITICAL but STILL RETURNS THE TOKEN to the caller (admin.py ~line
|
||||
475).
|
||||
|
||||
The token is delivered (admin gets their money) but there is no
|
||||
audit trail — if the admin later loses the token, there is no DB
|
||||
record to reclaim it from. Worse, the "out" row is missing so
|
||||
reconciliation is impossible.
|
||||
|
||||
Correct behaviour: if the DB store fails, the token should NOT be
|
||||
returned to the caller. Instead, raise an error so the admin knows
|
||||
the withdrawal failed and can retry.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from routstr.core import admin
|
||||
|
||||
src = inspect.getsource(admin.withdraw)
|
||||
# Find the store_cashu_transaction section
|
||||
store_idx = src.find("store_cashu_transaction")
|
||||
assert store_idx > 0, "withdraw endpoint must store the token"
|
||||
|
||||
# The section from store_cashu_transaction to the return statement
|
||||
# must NOT contain "return" before the except block — i.e. the
|
||||
# function must not return the token if the store raised.
|
||||
# Currently the code does:
|
||||
# try:
|
||||
# await store_cashu_transaction(...)
|
||||
# except Exception:
|
||||
# logger.critical(...)
|
||||
# return {"token": token, ...}
|
||||
#
|
||||
# The return is AFTER the except, meaning the token is returned even
|
||||
# when the store failed. The fix: re-raise or return an error
|
||||
# response inside the except block, before the return.
|
||||
section = src[store_idx:]
|
||||
# Check that there is a "return" after the except block (the bug)
|
||||
return_after_except = (
|
||||
"except Exception:" in section and "return" in section.split("except Exception:")[-1]
|
||||
)
|
||||
assert not return_after_except, (
|
||||
"FIX REQUIRED: admin.withdraw (admin.py ~line 475) returns the "
|
||||
"token to the caller even when store_cashu_transaction fails. "
|
||||
"A failed DB write means the token is delivered but has no audit "
|
||||
"trail. Fix: re-raise or return an error response inside the "
|
||||
"except block — do not return the token."
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V-E9: Existing emergency refund test window is too short (false green)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_except_pass_detector_window_is_wide_enough() -> None:
|
||||
"""The detector must catch an except/pass block beyond 500 characters."""
|
||||
anchor = "emergency_refund = amount"
|
||||
source = anchor + (" " * 520) + "except Exception:\n pass"
|
||||
|
||||
assert not _source_contains_except_pass(source, anchor, window=500)
|
||||
assert _source_contains_except_pass(source, anchor, window=1000)
|
||||
@@ -1510,11 +1510,8 @@ async def test_credit_balance_msat_unit_not_converted() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credit_balance_survives_audit_store_failure() -> None:
|
||||
"""A failure writing the CashuTransaction history record must not undo the
|
||||
already-committed balance credit. (The silent swallow is a known
|
||||
audit-trail gap slated for its own fix — this test pins the financial
|
||||
invariant that the user keeps their credit, not the swallow itself.)"""
|
||||
async def test_credit_balance_propagates_audit_store_failure_after_credit() -> None:
|
||||
"""A final transaction-history failure propagates after committing credit."""
|
||||
mock_key = Mock()
|
||||
mock_key.balance = 0
|
||||
mock_key.hashed_key = "test_hash"
|
||||
@@ -1531,9 +1528,9 @@ async def test_credit_balance_survives_audit_store_failure() -> None:
|
||||
"routstr.wallet.store_cashu_transaction",
|
||||
side_effect=Exception("history table locked"),
|
||||
):
|
||||
amount = await credit_balance("cashuAtest", mock_key, mock_session)
|
||||
with pytest.raises(Exception, match="history table locked"):
|
||||
await credit_balance("cashuAtest", mock_key, mock_session)
|
||||
|
||||
assert amount == 1_000_000
|
||||
assert mock_session.commit.called
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user