Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ and [Semantic Versioning](https://semver.org/).
([#452](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/452)).

### Changed / Internal
- **Pinned that an outage-refused payment never burns the token.** New
cloud-lab lane (`run-rejection-safety.sh`): a payment refused while the
mint is down must leave every proof UNSPENT at the mint (NUT-07) and
the same token must still buy a session after recovery; a replayed
token must never buy a second session (#423-class property).
([#512](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/512))


- **Recorded the captive-portal bundle-location decision.** The portal is
consumed as a hash-pinned CI-built artifact rather than merged into this
Expand Down
53 changes: 53 additions & 0 deletions tests/cloud-lab/run-rejection-safety.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# run-rejection-safety.sh — three-phase outage-safety e2e: a payment refused
# while the mint is down must not burn the token.
#
# A1 (mint up) mints and stashes the token; A2 (mint stopped, client run
# with --no-deps so compose cannot restart the mint through depends_on)
# pays and expects a refusal without a crash; B (mint back) proves every
# proof UNSPENT at the mint (NUT-07) and that the same token still buys a
# session. The token is handed between phases via .rejection-state.json —
# the same shape as run-keyset-rotation.sh.
#
# Usage (from tests/cloud-lab/):
# ./run-rejection-safety.sh # full run, teardown at the end
# KEEP=1 ./run-rejection-safety.sh # leave the lab up for inspection
#
# Isolation knobs (see README "Per-checkout project isolation"):
# COMPOSE_PROJECT_NAME project isolation
# CLOUD_LAB_EXTRA_COMPOSE extra -f override (e.g. strip host ports)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"

COMPOSE=(docker compose -f docker-compose.yml)
if [ -n "${CLOUD_LAB_EXTRA_COMPOSE:-}" ]; then
COMPOSE+=(-f "$CLOUD_LAB_EXTRA_COMPOSE")
fi

cleanup() {
# The phases stash a live bearer token into the working tree; an
# aborted run must not leave ecash sitting in a checkout.
rm -f "$SCRIPT_DIR/.rejection-state.json"
if [ "${KEEP:-0}" != "1" ]; then
"${COMPOSE[@]}" down -v >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT

echo "== Phase A1: mint up — fund and stash the token"
"${COMPOSE[@]}" up -d --build mint mint-fees upstream >/dev/null
REJECTION_LANE=1 "${COMPOSE[@]}" run --rm -e REJECTION_LANE --entrypoint sh client \
-c 'rm -rf /tests/__pycache__ && cd /tests && python3 -m pytest -q test_rejection_safety.py::TestOutageDoesNotBurnToken -k phase_a1'

echo "== Phase A2: stop the mint — the payment must be refused, not a crash"
"${COMPOSE[@]}" stop mint >/dev/null
REJECTION_LANE=1 "${COMPOSE[@]}" run --rm --no-deps -e REJECTION_LANE --entrypoint sh client \
-c 'rm -rf /tests/__pycache__ && cd /tests && python3 -m pytest -q test_rejection_safety.py::TestOutageDoesNotBurnToken -k phase_a2'

echo "== Phase B: mint back — the same token must still spend"
"${COMPOSE[@]}" start mint >/dev/null
REJECTION_LANE=1 "${COMPOSE[@]}" run --rm -e REJECTION_LANE --entrypoint sh client \
-c 'rm -rf /tests/__pycache__ && cd /tests && python3 -m pytest -q test_rejection_safety.py::TestOutageDoesNotBurnToken -k phase_b'

echo "== Rejection-safety run complete"
142 changes: 142 additions & 0 deletions tests/cloud-lab/test_rejection_safety.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""
test_rejection_safety.py — a rejected payment must never burn the token.

The #423-class property, pinned at the tollgate HTTP surface: a payment
refused while the mint is unreachable must leave the customer's ecash
intact — every proof still UNSPENT at the mint (NUT-07), and the very
same token still able to buy a session once the mint recovers.

The lane is three-phase, driven by ./run-rejection-safety.sh from the
host (the client container has no docker control, and `compose run`
would restart a stopped mint through the client's depends_on — hence the
--no-deps step): A1 mints and stashes the token; A2 pays during the
outage and must be refused without a crash; B proves the token survived
byte for byte. The phase tests are gated on REJECTION_LANE=1 and skip in
a default `client` run, exactly like the keyset-rotation lane.

test_mint_failure.py covers graceful degradation with FRESH tokens per
attempt (its docker-gated legs skip in-container); test_swap_fees.py
covers the below-fee refusal's spentness. The same-token-across-outage
leg is pinned only here.
"""

import json
import os
import random

import pytest
import requests

from conftest import (
MINT_URL,
UPSTREAM_URL,
build_payment_event,
create_cashu_token,
wait_for,
)
from test_swap_fees import assert_token_unspent

STATE_FILE = os.path.join(os.path.dirname(__file__), ".rejection-state.json")

REJECTION_LANE = os.environ.get("REJECTION_LANE") == "1"


def _mac():
# Locally-administered, session-random: allotments are cumulative per
# MAC, so a stable MAC would couple this suite to every other run.
return "02:%02x:%02x:%02x:%02x:%02x" % tuple(random.randbytes(5))


def pay(token, upstream_pubkey, customer_identity, mac):
customer_sec, customer_pub = customer_identity
event = build_payment_event(customer_sec, customer_pub, upstream_pubkey, mac, token)
return requests.post(f"{UPSTREAM_URL}?mac={mac}", json=event, timeout=60)


def _load_state():
with open(STATE_FILE) as f:
return json.load(f)


def _save_state(state):
with open(STATE_FILE, "w") as f:
json.dump(state, f)


class TestOutageDoesNotBurnToken:

@pytest.mark.skipif(not REJECTION_LANE, reason="phase tests need ./run-rejection-safety.sh (REJECTION_LANE=1)")
def test_phase_a1_mint_and_stash_token(
self, upstream_health, ecash_wallet
):
"""Mint is up: fund the wallet, stash the token and its paying MAC
for the outage phase."""
token = create_cashu_token(ecash_wallet, 50)
_save_state({"token": token, "mac": _mac()})

@pytest.mark.skipif(not REJECTION_LANE, reason="phase tests need ./run-rejection-safety.sh (REJECTION_LANE=1)")
def test_phase_a2_refusal_during_outage(
self, upstream_health, upstream_pubkey, customer_identity
):
"""Mint is down (the script stopped it, and this phase runs with
--no-deps so compose cannot restart it). The payment must be
refused — not crash, not 500 — and the tollgate must stay up.

A 1022 session event here would mean the tollgate now accepts
payments during mint outages (offline acceptance); if that
becomes intended behavior, this pin must be consciously updated
alongside the settlement guarantees such a mode needs."""
state = _load_state()
r = pay(state["token"], upstream_pubkey, customer_identity, state["mac"])

assert r.status_code >= 400 or r.json().get("kind") == 21023, (
f"refusal expected during outage, got HTTP {r.status_code}: {r.text[:300]}"
)
assert requests.get(UPSTREAM_URL, timeout=10).status_code == 200, (
"tollgate crashed on the outage refusal"
)

@pytest.mark.skipif(not REJECTION_LANE, reason="phase tests need ./run-rejection-safety.sh (REJECTION_LANE=1)")
def test_phase_b_same_token_still_spends(
self, upstream_health, upstream_pubkey, customer_identity
):
"""Mint is back (the script restarted it). The token refused
during the outage must be fully UNSPENT at the mint and must
still buy a session, byte for byte."""
assert wait_for(f"{MINT_URL}/v1/keys", timeout=60), "mint did not recover"

state = _load_state()

# The mint is the authority: the refusal path consumed nothing.
assert_token_unspent(state["token"], mint_url=MINT_URL)

r = pay(state["token"], upstream_pubkey, customer_identity, state["mac"])
assert r.status_code == 200, (
f"token refused during the outage no longer spends: HTTP {r.status_code}: {r.text[:300]}"
)
assert r.json().get("kind") == 1022, (
f"expected a session event, got: {r.text[:300]}"
)


class TestReplaySafety:

def test_replayed_token_grants_no_second_session(
self, upstream_health, upstream_pubkey, ecash_wallet, customer_identity
):
"""A token that already bought a session must not buy another."""
token = create_cashu_token(ecash_wallet, 50)
mac = _mac()

r1 = pay(token, upstream_pubkey, customer_identity, mac)
assert r1.status_code == 200 and r1.json().get("kind") == 1022, (
f"first payment failed: HTTP {r1.status_code}: {r1.text[:300]}"
)

r2 = pay(token, upstream_pubkey, customer_identity, mac)
assert not (r2.status_code == 200 and r2.json().get("kind") == 1022), (
f"replayed token bought a second session: HTTP {r2.status_code}: {r2.text[:300]}"
)
assert requests.get(UPSTREAM_URL, timeout=10).status_code == 200, (
"tollgate crashed on the replay"
)