Files
plm-lottery/tests/unit/test_migrations.py
T
davideandClaude Sonnet 5.5 db24287ea5 Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.

- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
  master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
  `draw_confirmations` is copied from round_config. The plaintext column is written only by
  the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
  bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
  lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
  `closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
  reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
  `draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
  asks every server, the active one included, for the header at that exact height (valid
  work, >= 2 other answers, strict majority of all); no agreement means the same height is
  asked again and `draw_header_corroboration_failed` is written once per distinct reported
  hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
  to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
  one transaction; a round closing with no participants reveals too. A round opened before
  this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
  the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
  /admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
  draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
  (RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.

Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:29:09 +02:00

199 lines
10 KiB
Python

"""B-97 (DAT-75, DAT-56, ARC-62, ARC-64): the Alembic chain is executed, not assumed.
The two expression indexes that carry invariants — `ix_rounds_single_active` ("at
most one active round", B-09) and `ix_users_username_lower` ("usernames are
case-insensitive", B-57) — are invisible to autogenerate, and `batch_alter_table`
on SQLite rebuilds the table and drops them with only a warning. Every other test
builds its schema with `Base.metadata.create_all`, so a migration that lost one of
them would reach production unnoticed. This one runs `alembic upgrade head` on an
empty database, exactly as the container does at startup.
Alembic runs in a subprocess: env.py calls `asyncio.run` and `fileConfig`, neither of
which belongs inside the pytest process.
"""
import os
import re
import sqlite3
import subprocess
import sys
from pathlib import Path
import pytest
_ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture(scope="module")
def migrated_db(tmp_path_factory) -> Path:
db_path = tmp_path_factory.mktemp("migrations") / "migrated.db"
env = {**os.environ, "DATABASE_URL": f"sqlite+aiosqlite:///{db_path}"}
result = subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
cwd=_ROOT,
env=env,
capture_output=True,
text=True,
timeout=120,
)
assert result.returncode == 0, f"alembic upgrade head failed:\n{result.stderr[-3000:]}"
return db_path
def _index_sql(db_path: Path, name: str) -> str | None:
with sqlite3.connect(db_path) as conn:
row = conn.execute("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?", (name,)).fetchone()
return re.sub(r"\s+", " ", row[0]).lower() if row else None
def test_the_expression_indexes_survive_the_whole_migration_chain(migrated_db):
"""DAT-75 / ARC-62 / B-97: both indexes exist, with their definition, at head."""
single_active = _index_sql(migrated_db, "ix_rounds_single_active")
assert single_active is not None, "ix_rounds_single_active was lost by a migration"
assert "unique index" in single_active
assert "on rounds ((1))" in single_active
for status in ("open", "closing", "drawing", "paying_out"):
assert f"'{status}'" in single_active
username_lower = _index_sql(migrated_db, "ix_users_username_lower")
assert username_lower is not None, "ix_users_username_lower was lost by a migration"
assert "unique index" in username_lower
assert "lower(username)" in username_lower
def test_the_migrated_database_still_enforces_both_invariants(migrated_db):
"""DAT-75 / B-97: a second active round and a case-variant username are refused."""
with sqlite3.connect(migrated_db) as conn:
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('open', '2026-01-01 00:00:00')")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closing', '2026-01-01 00:00:00')")
# A closed round is not active, so any number of them may coexist.
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closed', '2026-01-01 00:00:00')")
columns = "(username, password_hash, derivation_index, address, cached_balance_sats, token_version, created_at)"
conn.execute(f"INSERT INTO users {columns} VALUES ('Alice', 'x', 0, 'plm1qa', 0, 0, '2026-01-01 00:00:00')")
with pytest.raises(sqlite3.IntegrityError):
conn.execute(f"INSERT INTO users {columns} VALUES ('aLICE', 'x', 1, 'plm1qb', 0, 0, '2026-01-01 00:00:00')")
def _alembic(db_path: Path, *args: str) -> None:
env = {**os.environ, "DATABASE_URL": f"sqlite+aiosqlite:///{db_path}"}
result = subprocess.run(
[sys.executable, "-m", "alembic", *args], cwd=_ROOT, env=env, capture_output=True, text=True, timeout=120
)
assert result.returncode == 0, f"alembic {' '.join(args)} failed:\n{result.stderr[-3000:]}"
def test_pending_transaction_txids_table_matches_the_model(migrated_db):
"""B-77 (DAT-70): the migrated table has the model's columns, the unique txid and the
index on the owning row."""
from app.db.models import PendingTransactionTxid
with sqlite3.connect(migrated_db) as conn:
columns = {row[1]: row for row in conn.execute("PRAGMA table_info(pending_transaction_txids)")}
sql = conn.execute("SELECT sql FROM sqlite_master WHERE name = 'pending_transaction_txids'").fetchone()[0]
indexes = {row[1] for row in conn.execute("PRAGMA index_list(pending_transaction_txids)")}
assert set(columns) == {c.name for c in PendingTransactionTxid.__table__.columns}
assert "unique" in sql.lower()
assert "ix_pending_transaction_txids_pending_transaction_id" in indexes
def test_existing_pending_transactions_are_backfilled_with_the_txids_they_remember(tmp_path):
"""B-77 (DAT-70): upgrading a database that already has rows records each one's
current txid with its raw transaction, and the txid a bump replaced with an empty
raw_hex — and the expression indexes survive the step."""
db_path = tmp_path / "old.db"
_alembic(db_path, "upgrade", "477bddae1a84")
with sqlite3.connect(db_path) as conn:
columns = (
"(kind, current_txid, fee_rate_sat_vb, raw_tx_hex, broadcast_at, last_broadcast_at, status, "
"replaced_by_txid, attempt_count, updated_at)"
)
stamp = "'2026-01-01 00:00:00'"
conn.execute(
f"INSERT INTO pending_transactions {columns} VALUES "
f"('bet', 'cc', 2, 'rawcc', {stamp}, {stamp}, 'pending', 'bb', 2, {stamp})"
)
conn.execute(
f"INSERT INTO pending_transactions {columns} VALUES "
f"('withdrawal', 'dd', 1, 'rawdd', {stamp}, {stamp}, 'building', NULL, 1, {stamp})"
)
_alembic(db_path, "upgrade", "head")
with sqlite3.connect(db_path) as conn:
rows = sorted(conn.execute("SELECT pending_transaction_id, txid, raw_hex FROM pending_transaction_txids"))
assert rows == [(1, "bb", ""), (1, "cc", "rawcc"), (2, "dd", "rawdd")]
assert _index_sql(db_path, "ix_rounds_single_active") is not None
assert _index_sql(db_path, "ix_users_username_lower") is not None
def test_failed_at_and_paused_reason_are_backfilled_for_existing_rows(tmp_path):
"""B-78 (DAT-36, DAT-69, DAT-72): rows that are already failed start their 14-day
watch from their last update, and an instance that is already paused was paused by its
operator; the expression indexes survive the add_column steps."""
db_path = tmp_path / "old.db"
_alembic(db_path, "upgrade", "a3f5c2d9b7e1")
with sqlite3.connect(db_path) as conn:
columns = (
"(kind, current_txid, fee_rate_sat_vb, raw_tx_hex, broadcast_at, last_broadcast_at, status, "
"attempt_count, updated_at)"
)
conn.execute(
f"INSERT INTO pending_transactions {columns} VALUES "
"('bet', 'aa', 1, 'raw', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 'failed', 1, '2026-02-03 04:05:06')"
)
conn.execute(
f"INSERT INTO pending_transactions {columns} VALUES "
"('bet', 'bb', 1, 'raw', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 'pending', 1, '2026-02-03 04:05:06')"
)
round_config = [r[1] for r in conn.execute("PRAGMA table_info(round_config)")]
assert "paused_reason" not in round_config
conn.execute(
"INSERT INTO round_config (fee_address, bet_amount_sats, round_duration_seconds, round_cooldown_seconds, "
"fee_rate_sat_vb, rbf_timeout_seconds, draw_animation_seconds, paused, updated_at) "
"VALUES ('', 1000000000, 600, 30, 1, 900, 20, 1, '2026-01-01 00:00:00')"
)
_alembic(db_path, "upgrade", "head")
with sqlite3.connect(db_path) as conn:
failed_at = dict(conn.execute("SELECT current_txid, failed_at FROM pending_transactions"))
reason = conn.execute("SELECT paused_reason FROM round_config").fetchone()[0]
assert failed_at == {"aa": "2026-02-03 04:05:06", "bb": None}
assert reason == "operator"
assert _index_sql(db_path, "ix_rounds_single_active") is not None
assert _index_sql(db_path, "ix_users_username_lower") is not None
def test_the_commit_reveal_columns_and_the_draw_position_index_exist_at_head(migrated_db):
"""B-73 (DAT-62 … DAT-66): the new columns, their defaults and the unique position index."""
with sqlite3.connect(migrated_db) as conn:
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
for column in ("server_seed_enc", "server_seed", "server_seed_hash", "participants_hash", "draw_height"):
assert column in rounds and rounds[column][3] == 0 # nullable
assert rounds["draw_confirmations"][3] == 1 and rounds["draw_confirmations"][4] in ("3", "'3'")
participants = {r[1]: r for r in conn.execute("PRAGMA table_info(round_participants)")}
assert participants["bet_vout"][3] == 1 and participants["bet_vout"][4] in ("0", "'0'")
assert participants["draw_position"][3] == 0
config = {r[1]: r for r in conn.execute("PRAGMA table_info(round_config)")}
assert config["draw_confirmations"][4] in ("3", "'3'")
sql = _index_sql(migrated_db, "ux_round_participants_round_draw_position")
assert sql is not None and "unique" in sql and "(round_id, draw_position)" in sql
def test_two_participants_cannot_share_a_draw_position_but_null_positions_coexist(migrated_db):
with sqlite3.connect(migrated_db) as conn:
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closed', '2026-01-01 00:00:00')")
round_id = conn.execute("SELECT max(id) FROM rounds").fetchone()[0]
columns = "(round_id, user_id, bet_amount_sats, bet_txid, broadcast_at, status, draw_position)"
def insert(user_id, position):
conn.execute(
f"INSERT INTO round_participants {columns} VALUES (?, ?, 1, 'tx', '2026-01-01 00:00:00', 'confirmed', ?)",
(round_id, user_id, position),
)
insert(1, None)
insert(2, None) # NULLs do not collide
insert(3, 0)
with pytest.raises(sqlite3.IntegrityError):
insert(4, 0)