diff --git a/.env.example b/.env.example index f68f5c5..cf706a3 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,9 @@ -SECRET_KEY=replace-this-with-a-long-random-string -APP_ENV=production -DATABASE_URL=postgresql+psycopg://user:password@host/dbname -APP_BASE_URL=https://myboker.org -RATELIMIT_STORAGE_URI=redis://localhost:6379/0 -MAIL_SERVER=smtp.sendgrid.net +SECRET_KEY=local-dev-secret-change-me +APP_ENV=development +APP_BASE_URL=http://localhost:5000 +MAIL_SERVER= MAIL_PORT=587 MAIL_USE_TLS=true -MAIL_USERNAME=apikey -MAIL_PASSWORD=your-sendgrid-api-key +MAIL_USERNAME= +MAIL_PASSWORD= MAIL_DEFAULT_SENDER=noreply@myboker.org diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..f68f5c5 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,11 @@ +SECRET_KEY=replace-this-with-a-long-random-string +APP_ENV=production +DATABASE_URL=postgresql+psycopg://user:password@host/dbname +APP_BASE_URL=https://myboker.org +RATELIMIT_STORAGE_URI=redis://localhost:6379/0 +MAIL_SERVER=smtp.sendgrid.net +MAIL_PORT=587 +MAIL_USE_TLS=true +MAIL_USERNAME=apikey +MAIL_PASSWORD=your-sendgrid-api-key +MAIL_DEFAULT_SENDER=noreply@myboker.org diff --git a/README.md b/README.md index c0f974b..bf32e32 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Then open: ## Environment Values -The app reads these from `.env`: +The app reads these from `.env`. Use `.env.example` for local development and `.env.production.example` as the production checklist. - `SECRET_KEY` - `APP_ENV` @@ -94,7 +94,15 @@ The app reads these from `.env`: - `MAIL_PASSWORD` - `MAIL_DEFAULT_SENDER` -For public deployments, set `APP_ENV=production`. Production mode enables secure cookies and refuses to start with the development `SECRET_KEY` or the local SQLite database. Use PostgreSQL for `DATABASE_URL`, then run `flask --app app db upgrade` during deploy. Set `RATELIMIT_STORAGE_URI` to a shared backend such as Redis so login and signup limits are enforced across processes. +For local development, omit `DATABASE_URL` to use the default SQLite database at `data/boker-dev.sqlite3`. + +For public deployments, set `APP_ENV=production`. Production mode enables secure cookies and refuses to start with the development `SECRET_KEY` or any SQLite database. Use PostgreSQL for `DATABASE_URL`, install dependencies from `requirements.txt`, then run: + +```bash +flask --app app db upgrade +``` + +Set `RATELIMIT_STORAGE_URI` to a shared backend such as Redis so login and signup limits are enforced across processes. ## Site Admin Access diff --git a/migrations/versions/0002_league_public_key.py b/migrations/versions/0002_league_public_key.py index 6d8eb5b..fba7488 100644 --- a/migrations/versions/0002_league_public_key.py +++ b/migrations/versions/0002_league_public_key.py @@ -6,9 +6,6 @@ Create Date: 2026-06-23 """ from __future__ import annotations -import secrets -import string - from alembic import op import sqlalchemy as sa @@ -18,28 +15,10 @@ down_revision = "0001_public_foundation" branch_labels = None depends_on = None -ALPHABET = string.ascii_lowercase + string.digits - - -def _key(existing: set[str], length: int = 6) -> str: - while True: - value = "".join(secrets.choice(ALPHABET) for _ in range(length)) - if value not in existing: - existing.add(value) - return value - def upgrade() -> None: op.add_column("leagues", sa.Column("public_key", sa.String(length=12), nullable=True)) - - bind = op.get_bind() - rows = bind.execute(sa.text("SELECT id FROM leagues")).mappings().all() - existing: set[str] = set() - for row in rows: - bind.execute( - sa.text("UPDATE leagues SET public_key = :public_key WHERE id = :id"), - {"public_key": _key(existing), "id": row["id"]}, - ) + op.execute("UPDATE leagues SET public_key = substr(replace(id, '-', ''), 1, 12) WHERE public_key IS NULL") with op.batch_alter_table("leagues") as batch_op: batch_op.alter_column("public_key", existing_type=sa.String(length=12), nullable=False) diff --git a/migrations/versions/0005_site_admin_accounts.py b/migrations/versions/0005_site_admin_accounts.py index 45c745c..34674d8 100644 --- a/migrations/versions/0005_site_admin_accounts.py +++ b/migrations/versions/0005_site_admin_accounts.py @@ -6,7 +6,7 @@ Create Date: 2026-06-28 """ from __future__ import annotations -from alembic import op +from alembic import context, op import sqlalchemy as sa @@ -17,6 +17,18 @@ depends_on = None def upgrade() -> None: + if context.is_offline_mode(): + op.add_column( + "users", + sa.Column( + "is_site_admin", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + return + columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("users")} if "is_site_admin" not in columns: op.add_column( @@ -31,6 +43,10 @@ def upgrade() -> None: def downgrade() -> None: + if context.is_offline_mode(): + op.drop_column("users", "is_site_admin") + return + columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("users")} if "is_site_admin" in columns: op.drop_column("users", "is_site_admin") diff --git a/tests/test_db_foundation.py b/tests/test_db_foundation.py new file mode 100644 index 0000000..7b80337 --- /dev/null +++ b/tests/test_db_foundation.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from datetime import date +import unittest + +from boker.db import database_extensions_available + + +@unittest.skipUnless( + database_extensions_available(), + "Database dependencies are not installed.", +) +class DatabaseFoundationTests(unittest.TestCase): + def setUp(self): + from app import create_app + from boker.db import db + + self.db = db + self.app = create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + } + ) + self.ctx = self.app.app_context() + self.ctx.push() + self.db.create_all() + + def tearDown(self): + self.db.session.remove() + self.db.drop_all() + self.db.engine.dispose() + self.ctx.pop() + + def test_owner_league_players_sessions_season_and_events(self): + from boker.db_models import LedgerEvent, LeagueMembership, PokerSession + from boker.ledger_repositories import append_ledger_event + from boker.league_repositories import ( + create_league, + create_player, + create_poker_session, + create_season, + create_user, + user_has_league_role, + ) + + owner = create_user("Owner@Example.com", "correct horse battery staple") + self.db.session.flush() + league = create_league(owner, "Friday Poker", "friday-poker") + player = create_player(league.id, "Alex", "alex") + season = create_season( + league.id, + "Fall 2026", + start_date=date(2026, 9, 1), + end_date=date(2026, 12, 31), + ) + self.db.session.flush() + + first_session = create_poker_session( + league.id, + date(2026, 10, 2), + season_id=season.id, + status="open", + ) + second_session = create_poker_session( + league.id, + date(2026, 10, 2), + season_id=season.id, + ) + self.db.session.flush() + + append_ledger_event( + league.id, + first_session.id, + "buyin", + 2500, + owner.id, + player_id=player.id, + ) + append_ledger_event( + league.id, + first_session.id, + "paid", + 1000, + owner.id, + player_id=player.id, + ) + append_ledger_event( + league.id, + first_session.id, + "session_close", + 9999, + owner.id, + note="Closed after payout review.", + ) + self.db.session.commit() + + membership = LeagueMembership.query.filter_by( + league_id=league.id, + user_id=owner.id, + ).one() + sessions = PokerSession.query.order_by(PokerSession.sequence_on_date.asc()).all() + events = LedgerEvent.query.order_by(LedgerEvent.created_at.asc()).all() + + self.assertEqual(owner.email, "owner@example.com") + self.assertEqual(membership.role, "owner") + self.assertTrue(user_has_league_role(owner.id, league.id, {"owner"})) + self.assertEqual([s.sequence_on_date for s in sessions], [1, 2]) + self.assertEqual(sessions[0].season_id, season.id) + self.assertEqual(events[1].event_type, "paid_out") + self.assertEqual(events[2].amount_cents, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_phase2_auth_leagues.py b/tests/test_phase2_auth_leagues.py new file mode 100644 index 0000000..3b2fde1 --- /dev/null +++ b/tests/test_phase2_auth_leagues.py @@ -0,0 +1,502 @@ +from __future__ import annotations + +from datetime import date +import unittest + +from boker.db import database_extensions_available + + +@unittest.skipUnless( + database_extensions_available(), + "Database dependencies are not installed.", +) +class Phase2AuthLeagueRouteTests(unittest.TestCase): + def setUp(self): + from app import create_app + from boker.db import db + + self.db = db + self.app = create_app( + { + "TESTING": True, + "SECRET_KEY": "test-secret", + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", + "WTF_CSRF_ENABLED": False, + } + ) + self.ctx = self.app.app_context() + self.ctx.push() + self.db.create_all() + self.client = self.app.test_client() + + def tearDown(self): + self.db.session.remove() + self.db.drop_all() + self.db.engine.dispose() + self.ctx.pop() + + def league_path(self, league=None) -> str: + if league is None: + from boker.db_models import League + + league = League.query.one() + return f"/l/{league.url_ref}" + + def create_verified_user(self, email="owner@example.com"): + from boker.db_models import utc_now + from boker.league_repositories import create_user + + user = create_user(email, "password123") + self.db.session.flush() + user.email_verified_at = utc_now() + self.db.session.commit() + return user + + def login_as(self, user): + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = user.id + + def create_logged_in_league(self, email="owner@example.com", name="Friday Poker"): + from boker.league_repositories import create_league + + owner = self.create_verified_user(email) + league = create_league(owner, name, name.lower().replace(" ", "-")) + self.db.session.commit() + self.login_as(owner) + return owner, league, self.league_path(league) + + def test_registration_starts_email_verification_before_login(self): + from boker.db_models import User + + response = self.client.post( + "/account/register", + data={ + "email": "Owner@Example.com", + "password": "password123", + "confirm_password": "password123", + }, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], "/account/verify-email") + + user = User.query.one() + self.assertEqual(user.email, "owner@example.com") + self.assertIsNone(user.email_verified_at) + self.assertIsNotNone(user.email_verification_code_hash) + + with self.client.session_transaction() as flask_session: + self.assertNotIn("user_id", flask_session) + self.assertEqual(flask_session["pending_verification_user_id"], user.id) + + def test_verified_user_can_create_league_and_owner_membership(self): + from boker.db_models import League, LeagueMembership, User + + user = self.create_verified_user("Owner@Example.com") + self.login_as(user) + + response = self.client.post( + "/leagues/new", + data={ + "name": "Friday Poker", + "slug": "", + "description": "Private home league", + }, + ) + self.assertEqual(response.status_code, 302) + + self.assertEqual(User.query.count(), 1) + league = League.query.one() + self.assertEqual(response.headers["Location"], self.league_path(league)) + membership = LeagueMembership.query.one() + self.assertEqual(league.slug, "friday-poker") + self.assertEqual(membership.user_id, user.id) + self.assertEqual(membership.role, "owner") + + dashboard = self.client.get(self.league_path(league)) + self.assertEqual(dashboard.status_code, 200) + self.assertIn(b"Friday Poker", dashboard.data) + + def test_legacy_admin_routes_are_not_registered_by_default(self): + response = self.client.get("/admin/login") + self.assertEqual(response.status_code, 404) + + def test_duplicate_league_slug_is_allowed_because_route_uses_public_key(self): + owner = self.create_verified_user() + self.login_as(owner) + self.client.post("/leagues/new", data={"name": "Friday Poker"}) + self.client.post("/leagues/new", data={"name": "Friday Poker"}) + + from boker.db_models import League + + self.assertEqual( + [league.slug for league in League.query.order_by(League.slug.asc()).all()], + ["friday-poker", "friday-poker"], + ) + + def test_legacy_uuid_league_route_redirects_to_public_key_route(self): + from boker.db import db + from boker.league_repositories import create_league, create_user + + owner = create_user("owner@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = owner.id + + response = self.client.get(f"/l/{league.id}/{league.slug}") + self.assertEqual(response.status_code, 301) + self.assertEqual(response.headers["Location"], self.league_path(league)) + + def test_league_dashboard_requires_membership(self): + from boker.db import db + from boker.league_repositories import create_league, create_user + + owner = create_user("owner@example.com", "password123") + other = create_user("other@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = other.id + + response = self.client.get(self.league_path(league)) + self.assertEqual(response.status_code, 403) + + def test_owner_can_add_archive_and_reactivate_player(self): + from boker.db_models import Player + + _, _, league_base = self.create_logged_in_league() + + response = self.client.post( + f"{league_base}/players", + data={ + "display_name": "Alex", + "slug": "", + "notes": "Usually hosts.", + }, + ) + self.assertEqual(response.status_code, 302) + + player = Player.query.one() + self.assertEqual(player.display_name, "Alex") + self.assertEqual(player.slug, "alex") + self.assertEqual(player.status, "active") + self.assertEqual(player.notes, "Usually hosts.") + + duplicate = self.client.post( + f"{league_base}/players", + data={"display_name": " alex "}, + ) + self.assertEqual(duplicate.status_code, 200) + self.assertIn(b"already exists", duplicate.data) + self.assertEqual(Player.query.count(), 1) + + archive = self.client.post(f"{league_base}/players/{player.id}/archive") + self.assertEqual(archive.status_code, 302) + self.db.session.refresh(player) + self.assertEqual(player.status, "archived") + + reactivate = self.client.post(f"{league_base}/players/{player.id}/reactivate") + self.assertEqual(reactivate.status_code, 302) + self.db.session.refresh(player) + self.assertEqual(player.status, "active") + + def test_viewer_can_view_players_but_cannot_add_or_archive(self): + from boker.db import db + from boker.db_models import LeagueMembership, Player + from boker.league_repositories import create_league, create_player, create_user + + owner = create_user("owner@example.com", "password123") + viewer = create_user("viewer@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + player = create_player(league.id, "Alex", "alex") + db.session.flush() + db.session.add( + LeagueMembership( + league_id=league.id, + user_id=viewer.id, + role="viewer", + ) + ) + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = viewer.id + + league_base = self.league_path(league) + response = self.client.get(f"{league_base}/players") + self.assertEqual(response.status_code, 200) + self.assertIn(b"Alex", response.data) + self.assertNotIn(b"Add player", response.data) + + add = self.client.post( + f"{league_base}/players", + data={"display_name": "Blair"}, + ) + self.assertEqual(add.status_code, 403) + self.assertEqual(Player.query.count(), 1) + + archive = self.client.post(f"{league_base}/players/{player.id}/archive") + self.assertEqual(archive.status_code, 403) + self.db.session.refresh(player) + self.assertEqual(player.status, "active") + + def test_owner_can_create_same_day_sessions_and_close_them(self): + from boker.db_models import LedgerEvent, PokerSession + + _, _, league_base = self.create_logged_in_league() + + first = self.client.post( + f"{league_base}/sessions", + data={ + "session_date": "2026-10-02", + "label": "", + "status": "open", + "notes": "Main game", + }, + ) + second = self.client.post( + f"{league_base}/sessions", + data={ + "session_date": "2026-10-02", + "label": "Late table", + "status": "open", + }, + ) + + self.assertEqual(first.status_code, 302) + self.assertEqual(second.status_code, 302) + + sessions = PokerSession.query.order_by(PokerSession.sequence_on_date.asc()).all() + self.assertEqual([session.sequence_on_date for session in sessions], [1, 2]) + self.assertEqual(sessions[0].status, "open") + self.assertEqual(sessions[0].notes, "Main game") + self.assertEqual(sessions[1].display_label, "Late table") + + events = LedgerEvent.query.order_by(LedgerEvent.created_at.asc()).all() + self.assertEqual([event.event_type for event in events], ["session_open", "session_open"]) + + close = self.client.post(f"{league_base}/sessions/{sessions[0].id}/close") + self.assertEqual(close.status_code, 302) + self.db.session.refresh(sessions[0]) + self.assertEqual(sessions[0].status, "closed") + self.assertIsNotNone(sessions[0].closed_at) + self.assertEqual(LedgerEvent.query.count(), 3) + self.assertEqual( + LedgerEvent.query.order_by(LedgerEvent.created_at.desc()).first().event_type, + "session_close", + ) + + def test_closed_session_creation_does_not_append_marker_event_until_opened(self): + from boker.db_models import LedgerEvent, PokerSession + + _, _, league_base = self.create_logged_in_league() + self.client.post( + f"{league_base}/sessions", + data={"session_date": "2026-10-02", "status": "closed"}, + ) + + session = PokerSession.query.one() + self.assertEqual(session.status, "closed") + self.assertEqual(LedgerEvent.query.count(), 0) + + open_response = self.client.post(f"{league_base}/sessions/{session.id}/open") + self.assertEqual(open_response.status_code, 302) + self.db.session.refresh(session) + self.assertEqual(session.status, "open") + self.assertIsNotNone(session.opened_at) + self.assertEqual(LedgerEvent.query.one().event_type, "session_open") + + def test_viewer_can_view_sessions_but_cannot_create_or_close(self): + from boker.db import db + from boker.db_models import LeagueMembership, PokerSession + from boker.league_repositories import create_league, create_poker_session, create_user + + owner = create_user("owner@example.com", "password123") + viewer = create_user("viewer@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + session = create_poker_session(league.id, date(2026, 10, 2), status="open") + db.session.flush() + db.session.add( + LeagueMembership( + league_id=league.id, + user_id=viewer.id, + role="viewer", + ) + ) + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = viewer.id + + league_base = self.league_path(league) + response = self.client.get(f"{league_base}/sessions") + self.assertEqual(response.status_code, 200) + self.assertIn(b"2026-10-02", response.data) + self.assertNotIn(b"Create session", response.data) + + create = self.client.post( + f"{league_base}/sessions", + data={"session_date": "2026-10-03"}, + ) + self.assertEqual(create.status_code, 403) + self.assertEqual(PokerSession.query.count(), 1) + + close = self.client.post(f"{league_base}/sessions/{session.id}/close") + self.assertEqual(close.status_code, 403) + self.db.session.refresh(session) + self.assertEqual(session.status, "open") + + def test_owner_can_append_events_and_view_db_backed_stats(self): + from boker.db_models import LedgerEvent, Player, PokerSession + + _, _, league_base = self.create_logged_in_league() + self.client.post(f"{league_base}/players", data={"display_name": "Alex"}) + self.client.post( + f"{league_base}/sessions", + data={"session_date": "2026-10-02", "status": "open"}, + ) + + player = Player.query.one() + session = PokerSession.query.one() + + buyin = self.client.post( + f"{league_base}/sessions/{session.id}", + data={ + "player_id": player.id, + "event_type": "buyin", + "amount": "20", + }, + ) + cashout = self.client.post( + f"{league_base}/sessions/{session.id}", + data={ + "player_id": player.id, + "event_type": "cashout", + "amount": "35", + }, + ) + paid = self.client.post( + f"{league_base}/sessions/{session.id}", + data={ + "player_id": player.id, + "event_type": "paid_out", + "amount": "15", + }, + ) + + self.assertEqual(buyin.status_code, 302) + self.assertEqual(cashout.status_code, 302) + self.assertEqual(paid.status_code, 302) + self.assertEqual(LedgerEvent.query.filter_by(player_id=player.id).count(), 3) + + leaderboard = self.client.get(f"{league_base}/leaderboard") + self.assertEqual(leaderboard.status_code, 200) + self.assertIn(b"Alex", leaderboard.data) + self.assertIn(b"15.00", leaderboard.data) + + detail = self.client.get(f"{league_base}/sessions/{session.id}") + self.assertEqual(detail.status_code, 200) + self.assertIn(b"cashout", detail.data) + self.assertIn(b"paid out", detail.data) + + player_detail = self.client.get(f"{league_base}/players/{player.id}") + self.assertEqual(player_detail.status_code, 200) + self.assertIn(b"Total net", player_detail.data) + self.assertIn(b"20.00", player_detail.data) + + def test_league_ledger_shows_and_resolves_player_debts(self): + from boker.db_models import LedgerEvent, Player, PokerSession + + _, _, league_base = self.create_logged_in_league() + self.client.post(f"{league_base}/players", data={"display_name": "Alex"}) + self.client.post( + f"{league_base}/sessions", + data={"session_date": "2026-10-02", "status": "open"}, + ) + + player = Player.query.one() + session = PokerSession.query.one() + self.client.post( + f"{league_base}/sessions/{session.id}", + data={"player_id": player.id, "event_type": "front", "amount": "50"}, + ) + self.client.post( + f"{league_base}/sessions/{session.id}", + data={"player_id": player.id, "event_type": "cashout", "amount": "20"}, + ) + + ledger = self.client.get(f"{league_base}/ledger") + self.assertEqual(ledger.status_code, 200) + self.assertIn(b"Debt resolution", ledger.data) + self.assertIn(b"Front receivables", ledger.data) + self.assertIn(b"30.00", ledger.data) + + repayment = self.client.post( + f"{league_base}/ledger", + data={ + "session_id": session.id, + "player_id": player.id, + "event_type": "debt_repayment", + "amount": "10", + }, + ) + self.assertEqual(repayment.status_code, 302) + self.assertEqual( + LedgerEvent.query.order_by(LedgerEvent.created_at.desc()).first().event_type, + "debt_repayment", + ) + + ledger = self.client.get(f"{league_base}/ledger") + self.assertEqual(ledger.status_code, 200) + self.assertIn(b"20.00", ledger.data) + + def test_viewer_cannot_append_ledger_events(self): + from boker.db import db + from boker.db_models import LeagueMembership, LedgerEvent + from boker.league_repositories import ( + create_league, + create_player, + create_poker_session, + create_user, + ) + + owner = create_user("owner@example.com", "password123") + viewer = create_user("viewer@example.com", "password123") + db.session.flush() + league = create_league(owner, "Private League", "private-league") + player = create_player(league.id, "Alex", "alex") + session = create_poker_session(league.id, date(2026, 10, 2), status="open") + db.session.flush() + db.session.add( + LeagueMembership( + league_id=league.id, + user_id=viewer.id, + role="viewer", + ) + ) + db.session.commit() + + with self.client.session_transaction() as flask_session: + flask_session["user_id"] = viewer.id + + response = self.client.post( + f"{self.league_path(league)}/sessions/{session.id}", + data={ + "player_id": player.id, + "event_type": "buyin", + "amount": "20", + }, + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(LedgerEvent.query.count(), 0) + + +if __name__ == "__main__": + unittest.main()