blockstream-satellite-api/server/tests/test_transmitter.py
Blockstream Satellite 50df236d6b Support multiple parallel logical message channels
The same server now can handle multiple logical channels, on which the
transmitter logic runs independently. That is, while previously a single
message would be in transmitting state at a time, now multiple messages
can be in transmitting state as long as they belong to distinct logical
channels.

The supported channels each have different permissions. The user channel
is where users can post, get, and delete messages as needed. In
contrast, the other channels do not grant all permissions to users. Some
are read-only (users can get but not post) and there is a channel (the
auth channel) on which users have no permissions (neither get nor post).

For the channels on which users do not have all permissions (get, post,
and delete), this patch adds admin-specific routes, which are prefixed
by /admin/. The /admin/ route is protected via SSL in production and
allows the admin host to send GET/POST/DELETE requests normally. Hence,
for instance, the admin host can post a message on the auth channel
(with POST /admin/order) and read it (with GET /admin/order) for
transmission over satellite, whereas regulars cannot. With this scheme,
the auth channel messages are accessible exclusively over satellite (and
not over the internet).

The admin routes were added to the following endpoints:
- /order/<uuid> (GET and DELETE requests)
- /order (POST request)
- /orders/<state> (GET request)
- /message/<tx_seq_num> (GET request)

The messages posted by the admin are not paid, so this patch removes the
requirement of invoice generation and payment. Only paid orders now
generate an invoice. Thus, the POST request to the /order/ endpoint does
not return an invoice for non-paid (admin-only) messages.

Also, this patch updates the queue page to display the orders separately
for each channel. The query string channel parameter determines which
channel the page shows.

Finally, this patch updates the events published into the Redis db on
transmission. The event includes the corresponding logical channel so
that SSE events can be subscribed independently for each channel.
2023-02-02 17:16:12 -03:00

521 lines
23 KiB
Python

import json
import pytest
from datetime import datetime, timedelta
from unittest.mock import patch
from common import generate_test_order, pay_invoice, confirm_tx
from constants import InvoiceStatus, OrderStatus, USER_CHANNEL
import transmitter
from database import db
from schemas import order_schema
from models import Order, TxRetry, TxConfirmation
from regions import Regions, all_region_numbers, region_number_list_to_code
from order_helpers import refresh_retransmission_table, \
get_next_retransmission, sent_or_received_criteria_met, assert_order_state
import constants
import server
@pytest.fixture
def app(mockredis):
app = server.create_app(from_test=True)
app.app_context().push()
yield app
server.teardown_app(app)
@pytest.fixture
def client(app):
with app.test_client() as client:
yield client
def assert_redis_call(mockredis, order):
msg = order_schema.dump(order)
msg = json.dumps(msg)
mockredis.publish.assert_called_with(channel='transmissions', message=msg)
@patch('orders.new_invoice')
def test_tx_start(mock_new_invoice, client, mockredis):
# Create two orders and pay only for the first.
first_order_uuid = generate_test_order(mock_new_invoice, client)['uuid']
second_order_uuid = generate_test_order(mock_new_invoice,
client,
order_id=2)['uuid']
first_db_order = Order.query.filter_by(uuid=first_order_uuid).first()
# The transmission should start immediately upon payment
pay_invoice(first_db_order.invoices[0], client)
assert_redis_call(mockredis, first_db_order)
# The expectation is that the first order gets transmitted and the second
# stays untouched. The invoice callback handler should call tx_start.
assert_order_state(first_order_uuid, 'transmitting')
assert first_db_order.tx_seq_num is not None
assert_order_state(second_order_uuid, 'pending')
second_db_order = Order.query.filter_by(uuid=second_order_uuid).first()
assert second_db_order.tx_seq_num is None
# Calling tx_start explicitly won't change anything since the second order
# is still unpaid
transmitter.tx_start()
second_db_order = Order.query.filter_by(uuid=second_order_uuid).first()
assert second_db_order.status == OrderStatus.pending.value
assert second_db_order.tx_seq_num is None
@patch('orders.new_invoice')
def test_tx_end(mock_new_invoice, client, mockredis):
# Create two orders and pay for both.
first_order_uuid = generate_test_order(mock_new_invoice, client,
bid=2000)['uuid']
second_order_uuid = generate_test_order(mock_new_invoice,
client,
bid=1000,
order_id=2)['uuid']
first_db_order = Order.query.filter_by(uuid=first_order_uuid).first()
second_db_order = Order.query.filter_by(uuid=second_order_uuid).first()
# As soon as the first order is paid, its transmission should start
# immediately.
pay_invoice(first_db_order.invoices[0], client)
assert_redis_call(mockredis, first_db_order)
# Meanwhile, if the second order is paid, its transmission cannot start
# immediately because the Tx line is still blocked by the first order.
pay_invoice(second_db_order.invoices[0], client)
# The expectation is that the first order gets transmitted and the second
# stays in paid state. The invoice callback handler should call tx_start.
assert_order_state(first_order_uuid, 'transmitting')
assert first_db_order.tx_seq_num is not None
assert_order_state(second_order_uuid, 'paid')
assert second_db_order.tx_seq_num is None
# The second order should start transmitting immediately after ending the
# first. The only prerequisite is that the first order is in sent state
# (after Tx confirmations) when ended.
confirm_tx(first_db_order.tx_seq_num, all_region_numbers, client)
if sent_or_received_criteria_met(first_db_order):
transmitter.tx_end(first_db_order)
assert_order_state(first_order_uuid, 'sent')
assert first_db_order.ended_transmission_at is not None
assert_order_state(second_order_uuid, 'transmitting')
assert second_db_order.tx_seq_num is not None
@patch('orders.new_invoice')
def test_assign_tx_seq_num(mock_new_invoice, client):
# make some orders
first_order_uuid = generate_test_order(mock_new_invoice, client)['uuid']
first_db_order = Order.query.filter_by(uuid=first_order_uuid).first()
assert first_db_order.tx_seq_num is None
second_order_uuid = generate_test_order(mock_new_invoice,
client,
order_id=2)['uuid']
second_db_order = Order.query.filter_by(uuid=second_order_uuid).first()
assert second_db_order.tx_seq_num is None
third_order_uuid = generate_test_order(mock_new_invoice,
client,
order_id=3)['uuid']
third_db_order = Order.query.filter_by(uuid=third_order_uuid).first()
assert third_db_order.tx_seq_num is None
transmitter.assign_tx_seq_num(first_db_order)
db.session.commit()
first_db_order = Order.query.filter_by(uuid=first_order_uuid).first()
assert first_db_order.tx_seq_num == 1
transmitter.assign_tx_seq_num(second_db_order)
db.session.commit()
second_db_order = Order.query.filter_by(uuid=second_order_uuid).first()
assert second_db_order.tx_seq_num == 2
transmitter.assign_tx_seq_num(third_db_order)
db.session.commit()
third_db_order = Order.query.filter_by(uuid=third_order_uuid).first()
assert third_db_order.tx_seq_num == 3
@patch('orders.new_invoice')
def test_startup_sequence(mock_new_invoice, client, mockredis):
# create an old transmitted order
transmitting_order_uuid = generate_test_order(
mock_new_invoice,
client,
invoice_status=InvoiceStatus.paid,
order_status=OrderStatus.transmitting,
started_transmission_at=datetime.utcnow() -
timedelta(minutes=5))['uuid']
# create two paid orders
first_sendable_order_uuid = generate_test_order(mock_new_invoice,
client,
order_id=5,
bid=1000)['uuid']
second_sendable_order_uuid = generate_test_order(mock_new_invoice,
client,
order_id=6,
bid=2000)['uuid']
first_sendable_db_order = \
Order.query.filter_by(uuid=first_sendable_order_uuid).first()
second_sendable_db_order = \
Order.query.filter_by(uuid=second_sendable_order_uuid).first()
pay_invoice(first_sendable_db_order.invoices[0], client)
pay_invoice(second_sendable_db_order.invoices[0], client)
# At startup, tx_start() should trigger the transmission of the highest
# bidder among the two paid orders, namely the second sendable order.
# However, this transmission is not possible until the transmitting order
# from the previous session times out and changes to confirming state.
transmitter.tx_start()
assert_order_state(first_sendable_order_uuid, 'paid')
assert_order_state(second_sendable_order_uuid, 'paid')
# Force the timeout by manipulating the transmission timestamp.
transmitting_db_order = \
Order.query.filter_by(uuid=transmitting_order_uuid).first()
transmitting_db_order.started_transmission_at = datetime.utcnow(
) - timedelta(seconds=constants.TX_CONFIRM_TIMEOUT_SECS + 1)
db.session.commit()
refresh_retransmission_table()
assert_order_state(transmitting_order_uuid, 'confirming')
# Now, tx_start() can trigger the new transmission.
transmitter.tx_start()
assert_order_state(first_sendable_order_uuid, 'paid')
assert_order_state(second_sendable_order_uuid, 'transmitting')
# At this point, the Tx hosts send Tx confirmations
confirm_tx(second_sendable_db_order.tx_seq_num, all_region_numbers, client)
# Finally, tx_end() shall end the first transmission and trigger the second
# transmission (the first sendable order).
transmitter.tx_end(second_sendable_db_order)
assert_order_state(first_sendable_order_uuid, 'transmitting')
assert_order_state(second_sendable_order_uuid, 'sent')
# The sequence numbers should reflect the transmission order
assert first_sendable_db_order.tx_seq_num == 2
assert second_sendable_db_order.tx_seq_num == 1
@patch('orders.new_invoice')
def test_retransmission(mock_new_invoice, client, mockredis):
# 1) Order that requires retransmission due to not being confirmed
# by all Tx regions within the time limit.
first_order_uuid = generate_test_order(mock_new_invoice, client,
bid=1000)['uuid']
# Pay invoice -> State changes from pending to transmitting.
first_order = Order.query.filter_by(uuid=first_order_uuid).first()
pay_invoice(first_order.invoices[0], client)
assert_order_state(first_order_uuid, 'transmitting')
assert_redis_call(mockredis, first_order)
# Confirm Tx over a single region -> State changes from transmitting to
# confirming.
confirm_tx(1, [all_region_numbers[0]], client)
assert_order_state(first_order_uuid, 'confirming')
# Manipulate the Tx confirmation timestamp such that it exceeds the time
# limit and later leads to a retransmission.
last_tx_confirmation = TxConfirmation.query.filter_by(
order_id=first_order.id).order_by(
TxConfirmation.created_at.desc()).first()
last_tx_confirmation.created_at = datetime.utcnow() - timedelta(
seconds=constants.TX_CONFIRM_TIMEOUT_SECS + 1)
db.session.commit()
# 2) Order that needs retransmission due to not receiving any confirmation
# at all within the timeout limit.
second_order_uuid = generate_test_order(mock_new_invoice, client,
bid=2000)['uuid']
# Pay invoice -> State changes from pending to transmitting.
second_order = Order.query.filter_by(uuid=second_order_uuid).first()
pay_invoice(second_order.invoices[0], client)
assert_order_state(second_order_uuid, 'transmitting')
assert_redis_call(mockredis, second_order)
# Manipulate the Tx start timestamp such that it exceeds the time limit and
# later leads to a retransmission.
second_order.started_transmission_at = datetime.utcnow() - timedelta(
seconds=constants.TX_CONFIRM_TIMEOUT_SECS + 1)
db.session.commit()
# 3) Order that transmits normally with no need for retransmission.
third_order_regions = [Regions.g18.value, Regions.e113.value]
third_order_uuid = generate_test_order(mock_new_invoice,
client,
bid=2000,
regions=third_order_regions)['uuid']
# Pay invoice. In this case, the state changes from pending to paid (not to
# transmitting) since the Tx line is still blocked by the second order.
third_order = Order.query.filter_by(uuid=third_order_uuid).first()
pay_invoice(third_order.invoices[0], client)
assert_order_state(third_order_uuid, 'paid')
# Detect and update all the required retransmissions
refresh_retransmission_table()
# The first and second orders should require retransmission. Also, the
# second order should have changed from transmitting to confirming.
retry_order = TxRetry.query.all()
assert len(retry_order) == 2
assert retry_order[0].order_id == first_order.id
assert retry_order[1].order_id == second_order.id
assert_order_state(second_order_uuid, 'confirming')
# Check the next order for retransmission, which should be the highest
# bidder among the two with pending retransmission (the second order)
order, retry_info = get_next_retransmission(USER_CHANNEL)
assert order and retry_info
assert order.id == second_order.id
assert retry_info.order_id == second_order.id
# At this point, a worker would see orders requiring retransmission and
# call tx_start.
transmitter.tx_start()
# The third order should be prioritized because it's not a retransmission.
assert_order_state(third_order_uuid, 'transmitting')
assert_redis_call(mockredis, third_order)
# Confirm Tx for all regions. That should end the transmission and kick off
# the next, namely the retransmission of the second order. When the
# retransmission starts, the second order goes back from confirming to
# transmitting state.
confirm_tx(3, third_order_regions, client)
assert_order_state(third_order_uuid, 'sent')
assert_order_state(second_order_uuid, 'transmitting')
assert_redis_call(mockredis, second_order)
# Now, pretend the second order received all the required confirmations
# such that its transmission ended and the next started.
confirm_tx(second_order.tx_seq_num, all_region_numbers, client)
assert_order_state(second_order_uuid, 'sent')
# tx_end (called under the hood by the Tx confirmation handler) should
# remove the second order from the tx_retries table and start transmitting
# the next order, namely the retransmission of the first.
retry_order = TxRetry.query.all()
assert len(retry_order) == 1
assert retry_order[0].order_id == first_order.id
assert retry_order[0].retry_count == 1
assert retry_order[0].last_attempt is not None
assert_order_state(first_order_uuid, 'transmitting')
# Besides, since the first order was confirmed by the first region before,
# now the retransmission should go over the remaining regions only.
expected_redis_order = first_order
expected_redis_order.region_code = region_number_list_to_code(
all_region_numbers[1:])
assert_redis_call(mockredis, expected_redis_order)
# Next, suppose no confirmations are sent for the first retransmission.
# That should lead to a second retransmission. Manipulate the
# retransmission info to make that happen.
t_last_attempt = retry_order[0].last_attempt
retry_order[0].last_attempt = t_last_attempt - \
timedelta(seconds=constants.TX_CONFIRM_TIMEOUT_SECS + 1)
db.session.commit()
# A worker would timeout the order and put it back to confirming state.
refresh_retransmission_table()
assert_order_state(first_order_uuid, 'confirming')
# Another worker would see the confirming order and call tx_start(),
# leading to the second retransmission.
transmitter.tx_start()
assert_order_state(first_order_uuid, 'transmitting')
assert_redis_call(mockredis, expected_redis_order)
retry_order = TxRetry.query.all()
assert len(retry_order) == 1
assert retry_order[0].order_id == first_order.id
assert retry_order[0].retry_count == 2
# Lastly, suppose this second retransmission receives Tx confirmations, but
# not all of the required ones. Hence, it should be retransmitted one more
# time. Again, manipulate the retransmission info to make that happen. This
# time, note it's the wait interval that determines the retransmission, not
# the timeout interval. Also, the wait interval should be applied to the
# last (most recent) Tx confirmation, not the last retransmission time.
confirm_tx(first_order.tx_seq_num, all_region_numbers[1:3], client)
assert_order_state(first_order_uuid, 'confirming')
for order in TxConfirmation.query.filter_by(order_id=first_order.id).all():
order.created_at = datetime.utcnow() - timedelta(
seconds=constants.TX_CONFIRM_TIMEOUT_SECS + 1)
db.session.commit()
transmitter.tx_start()
retry_order = TxRetry.query.all()
assert len(retry_order) == 1
assert retry_order[0].order_id == first_order.id
assert retry_order[0].retry_count == 3
@patch('orders.new_invoice')
def test_multichannel_transmission(mock_new_invoice, client, mockredis):
# Post orders on the gossip and btc-src channels via the admin endpoint
gossip_order_uuid1 = generate_test_order(mock_new_invoice,
client,
order_id=1,
bid=1000,
channel=constants.GOSSIP_CHANNEL,
admin=True)['uuid']
gossip_order_uuid2 = generate_test_order(mock_new_invoice,
client,
order_id=2,
bid=1000,
channel=constants.GOSSIP_CHANNEL,
admin=True)['uuid']
btc_order_uuid1 = generate_test_order(mock_new_invoice,
client,
order_id=3,
bid=2000,
channel=constants.BTC_SRC_CHANNEL,
admin=True)['uuid']
btc_order_uuid2 = generate_test_order(mock_new_invoice,
client,
order_id=4,
bid=2000,
channel=constants.BTC_SRC_CHANNEL,
admin=True)['uuid']
# Post regular user-channel orders (requiring payment)
user_order_uuid1 = generate_test_order(mock_new_invoice,
client,
order_id=5,
bid=1000)['uuid']
user_order_uuid2 = generate_test_order(mock_new_invoice,
client,
order_id=6,
bid=2000)['uuid']
gossip_db_order1 = \
Order.query.filter_by(uuid=gossip_order_uuid1).first()
btc_db_order1 = \
Order.query.filter_by(uuid=btc_order_uuid1).first()
user_db_order1 = \
Order.query.filter_by(uuid=user_order_uuid1).first()
# The first admin orders should immediately move to the transmitting state.
# The second admin orders are in paid state (auto/forcedly paid) and
# waiting. The user orders are both in pending state until payment.
assert_order_state(gossip_order_uuid1, 'transmitting')
assert_order_state(gossip_order_uuid2, 'paid')
assert_order_state(btc_order_uuid1, 'transmitting')
assert_order_state(btc_order_uuid2, 'paid')
assert_order_state(user_order_uuid1, 'pending')
assert_order_state(user_order_uuid2, 'pending')
# Pay for the first user-channel order and ensure it moves to the
# transmitting state while the ongoing transmissions in other channels
# remain in progress simultaneously
pay_invoice(user_db_order1.invoices[0], client)
assert_order_state(user_order_uuid1, 'transmitting')
assert_order_state(gossip_order_uuid1, 'transmitting')
assert_order_state(btc_order_uuid1, 'transmitting')
# Calling tx_start should have no impact on the state
transmitter.tx_start()
transmitter.tx_start(constants.GOSSIP_CHANNEL)
transmitter.tx_start(constants.BTC_SRC_CHANNEL)
assert_order_state(gossip_order_uuid1, 'transmitting')
assert_order_state(gossip_order_uuid2, 'paid')
assert_order_state(btc_order_uuid1, 'transmitting')
assert_order_state(btc_order_uuid2, 'paid')
assert_order_state(user_order_uuid1, 'transmitting')
assert_order_state(user_order_uuid2, 'pending')
# Next, assume the Tx hosts send Tx confirmations
confirm_tx(gossip_db_order1.tx_seq_num, all_region_numbers, client)
confirm_tx(btc_db_order1.tx_seq_num, all_region_numbers, client)
confirm_tx(user_db_order1.tx_seq_num, all_region_numbers, client)
# Once confirmed, the next admin orders should start automatically. The
# next user order does not start because the payment is still missing.
assert_order_state(gossip_order_uuid1, 'sent')
assert_order_state(gossip_order_uuid2, 'transmitting')
assert_order_state(btc_order_uuid1, 'sent')
assert_order_state(btc_order_uuid2, 'transmitting')
assert_order_state(user_order_uuid1, 'sent')
assert_order_state(user_order_uuid2, 'pending')
def generate_paid_test_orders():
user_channel_order_1 = Order(uuid='uuid_user',
unpaid_bid=2000,
message_size=10,
message_digest='some digest',
status=OrderStatus.paid.value)
gossip_order = Order(uuid='uuid_gossip',
unpaid_bid=2000,
message_size=10,
message_digest='some digest',
status=OrderStatus.paid.value,
channel=constants.GOSSIP_CHANNEL)
btc_order = Order(uuid='uuid_btc',
unpaid_bid=2000,
message_size=10,
message_digest='some digest',
status=OrderStatus.paid.value,
channel=constants.BTC_SRC_CHANNEL)
auth_order = Order(uuid='uuid_auth',
unpaid_bid=2000,
message_size=10,
message_digest='some digest',
status=OrderStatus.paid.value,
channel=constants.AUTH_CHANNEL)
db.session.add(user_channel_order_1)
db.session.add(gossip_order)
db.session.add(btc_order)
db.session.add(auth_order)
db.session.commit()
def test_tx_start_with_single_channel(client):
generate_paid_test_orders()
transmitter.tx_start(constants.USER_CHANNEL)
assert_order_state('uuid_user', 'transmitting')
assert_order_state('uuid_gossip', 'paid')
assert_order_state('uuid_btc', 'paid')
assert_order_state('uuid_auth', 'paid')
transmitter.tx_start(constants.GOSSIP_CHANNEL)
assert_order_state('uuid_user', 'transmitting')
assert_order_state('uuid_gossip', 'transmitting')
assert_order_state('uuid_btc', 'paid')
assert_order_state('uuid_auth', 'paid')
transmitter.tx_start(constants.BTC_SRC_CHANNEL)
assert_order_state('uuid_user', 'transmitting')
assert_order_state('uuid_gossip', 'transmitting')
assert_order_state('uuid_btc', 'transmitting')
assert_order_state('uuid_auth', 'paid')
transmitter.tx_start(constants.AUTH_CHANNEL)
assert_order_state('uuid_user', 'transmitting')
assert_order_state('uuid_gossip', 'transmitting')
assert_order_state('uuid_btc', 'transmitting')
assert_order_state('uuid_auth', 'transmitting')
def test_tx_start_without_channel(client):
generate_paid_test_orders()
transmitter.tx_start()
assert_order_state('uuid_user', 'transmitting')
assert_order_state('uuid_gossip', 'transmitting')
assert_order_state('uuid_btc', 'transmitting')
assert_order_state('uuid_auth', 'transmitting')