Remove password admin login, seed dev accounts, add regression tests

Admin flow:
- Remove /admin/login (password-based) and /admin/dev-login routes entirely
- admin_required now checks only the 'admin' role; redirects to auth.login
- auth/dev-login with an ADMIN_EMAILS address redirects directly to /admin/
- .env.example: replace ADMIN_PASSWORD with ADMIN_EMAILS=admin@beanflows.coffee

Dev seeding:
- Add dev_seed.py: idempotent upsert of 4 fixed accounts (admin, free,
  starter, pro) so every access tier is testable after dev_run.sh
- dev_run.sh: seed after migrations, show all 4 login shortcuts

Regression tests (37 passing):
- test_analytics.py: concurrent fetch_analytics calls return correct row
  counts (cursor thread-safety regression), column names are lowercase
- test_roles.py TestAdminAuthFlow: password login routes return 404,
  admin_required redirects to auth.login, dev-login grants admin role
  and redirects to admin panel when email is in ADMIN_EMAILS
- conftest.py: add mock_analytics fixture (fixes 7 pre-existing dashboard
  test errors); fix assertion text and lowercase metric param in tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-20 20:10:45 +01:00
parent fef9f3d705
commit d09ba91023
9 changed files with 425 additions and 69 deletions

View File

@@ -3,7 +3,6 @@ APP_NAME=BeanFlows
SECRET_KEY=change-me-generate-a-real-secret SECRET_KEY=change-me-generate-a-real-secret
BASE_URL=http://localhost:5001 BASE_URL=http://localhost:5001
DEBUG=true DEBUG=true
ADMIN_PASSWORD=admin
ADMIN_EMAILS=admin@beanflows.coffee ADMIN_EMAILS=admin@beanflows.coffee
# Database # Database

View File

@@ -46,6 +46,10 @@ info "Running migrations"
uv run --package beanflows python -m beanflows.migrations.migrate uv run --package beanflows python -m beanflows.migrations.migrate
ok "Migrations applied" ok "Migrations applied"
info "Seeding dev accounts"
uv run --package beanflows python -m beanflows.dev_seed
ok "Dev accounts ready"
info "Building CSS" info "Building CSS"
make css-build make css-build
ok "CSS built" ok "CSS built"
@@ -114,10 +118,13 @@ fi
echo "" echo ""
echo -e "${BOLD}Starting BeanFlows dev environment${NC}" echo -e "${BOLD}Starting BeanFlows dev environment${NC}"
echo "" echo ""
echo " app: http://localhost:5001" echo " app: http://localhost:5001"
echo " user-login: http://localhost:5001/auth/dev-login?email=trader@beanflows.coffee" echo ""
echo " admin-login: http://localhost:5001/auth/dev-login?email=admin@beanflows.coffee" echo " Login shortcuts (dev only):"
echo " admin-panel: http://localhost:5001/admin/dev-login" echo " admin: http://localhost:5001/auth/dev-login?email=admin@beanflows.coffee"
echo " free: http://localhost:5001/auth/dev-login?email=trader@beanflows.coffee"
echo " starter: http://localhost:5001/auth/dev-login?email=starter@beanflows.coffee"
echo " pro: http://localhost:5001/auth/dev-login?email=pro@beanflows.coffee"
if [ -n "$TUNNEL_URL" ]; then if [ -n "$TUNNEL_URL" ]; then
echo " tunnel: $TUNNEL_URL" echo " tunnel: $TUNNEL_URL"
fi fi

View File

@@ -1,7 +1,6 @@
""" """
Admin domain: password-protected admin panel for managing users, tasks, etc. Admin domain: role-gated admin panel for managing users, tasks, etc.
""" """
import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
@@ -19,20 +18,6 @@ bp = Blueprint(
) )
# =============================================================================
# Config
# =============================================================================
def get_admin_password() -> str:
"""Get admin password from env. Generate one if not set (dev only)."""
import os
password = os.getenv("ADMIN_PASSWORD", "")
if not password and config.DEBUG:
# In dev, use a default password
return "admin"
return password
# ============================================================================= # =============================================================================
# SQL Queries # SQL Queries
# ============================================================================= # =============================================================================
@@ -205,13 +190,12 @@ async def get_waitlist(limit: int = 500) -> list[dict]:
# ============================================================================= # =============================================================================
def admin_required(f): def admin_required(f):
"""Require admin authentication via password (is_admin session flag) or admin role.""" """Require the user to have the 'admin' role (granted via ADMIN_EMAILS or user_roles table)."""
@wraps(f) @wraps(f)
async def decorated(*args, **kwargs): async def decorated(*args, **kwargs):
is_password_admin = session.get("is_admin") if "admin" not in (g.get("user") or {}).get("roles", []):
is_role_admin = "admin" in (g.get("user") or {}).get("roles", []) await flash("Admin access required.", "error")
if not is_password_admin and not is_role_admin: return redirect(url_for("auth.login"))
return redirect(url_for("admin.login"))
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated return decorated
@@ -220,50 +204,13 @@ def admin_required(f):
# Routes # Routes
# ============================================================================= # =============================================================================
@bp.route("/dev-login")
async def dev_login():
"""Instant admin login for development. Only works in DEBUG mode."""
if not config.DEBUG:
return "Not available", 404
session["is_admin"] = True
await flash("Dev admin login.", "success")
return redirect(url_for("admin.index"))
@bp.route("/login", methods=["GET", "POST"])
@csrf_protect
async def login():
"""Admin login page."""
admin_password = get_admin_password()
if not admin_password:
await flash("Admin access not configured. Set ADMIN_PASSWORD env var.", "error")
return redirect(url_for("public.landing"))
if session.get("is_admin"):
return redirect(url_for("admin.index"))
if request.method == "POST":
form = await request.form
password = form.get("password", "")
if secrets.compare_digest(password, admin_password):
session["is_admin"] = True
await flash("Welcome, admin!", "success")
return redirect(url_for("admin.index"))
else:
await flash("Invalid password.", "error")
return await render_template("admin/login.html")
@bp.route("/logout", methods=["POST"]) @bp.route("/logout", methods=["POST"])
@csrf_protect @csrf_protect
async def logout(): async def logout():
"""Admin logout.""" """Log out from admin (clears full session)."""
session.pop("is_admin", None) session.clear()
await flash("Logged out of admin.", "info") await flash("Logged out.", "info")
return redirect(url_for("admin.login")) return redirect(url_for("auth.login"))
@bp.route("/") @bp.route("/")

View File

@@ -345,6 +345,9 @@ async def dev_login():
await ensure_admin_role(user_id, email) await ensure_admin_role(user_id, email)
await flash(f"Dev login as {email}", "success") await flash(f"Dev login as {email}", "success")
# Drop admins straight into the panel
if email.lower() in config.ADMIN_EMAILS:
return redirect(url_for("admin.index"))
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))

View File

@@ -0,0 +1,74 @@
"""
Seed dev accounts for local development. Idempotent — safe to run multiple times.
Creates four fixed accounts so you can test every access tier immediately:
admin@beanflows.coffee — admin role, free plan
trader@beanflows.coffee — free plan (no subscription)
starter@beanflows.coffee — active starter subscription
pro@beanflows.coffee — active pro subscription
Login via: http://localhost:5001/auth/dev-login?email=<email>
"""
import asyncio
from datetime import datetime, timedelta
from .core import close_db, execute, fetch_one, init_db
# email, role (or None), plan (or None for free)
ACCOUNTS = [
("admin@beanflows.coffee", "admin", None),
("trader@beanflows.coffee", None, None),
("starter@beanflows.coffee", None, "starter"),
("pro@beanflows.coffee", None, "pro"),
]
async def _upsert_user(email: str) -> int:
row = await fetch_one("SELECT id FROM users WHERE email = ?", (email,))
if row:
return row["id"]
now = datetime.utcnow().isoformat()
return await execute(
"INSERT INTO users (email, created_at) VALUES (?, ?)",
(email, now),
)
async def _upsert_subscription(user_id: int, plan: str) -> None:
now = datetime.utcnow().isoformat()
period_end = (datetime.utcnow() + timedelta(days=365)).isoformat()
existing = await fetch_one(
"SELECT id FROM subscriptions WHERE user_id = ?", (user_id,)
)
if existing:
await execute(
"UPDATE subscriptions SET plan = ?, status = 'active', current_period_end = ? WHERE user_id = ?",
(plan, period_end, user_id),
)
else:
await execute(
"""INSERT INTO subscriptions (user_id, plan, status, current_period_end, created_at)
VALUES (?, ?, 'active', ?, ?)""",
(user_id, plan, period_end, now),
)
async def seed() -> None:
await init_db()
for email, role, plan in ACCOUNTS:
user_id = await _upsert_user(email)
if role:
await execute(
"INSERT OR IGNORE INTO user_roles (user_id, role) VALUES (?, ?)",
(user_id, role),
)
if plan:
await _upsert_subscription(user_id, plan)
tag = f"[{role or plan or 'free'}]"
print(f" {email} {tag}")
await close_db()
if __name__ == "__main__":
asyncio.run(seed())

View File

@@ -241,3 +241,64 @@ def sign_payload(payload_bytes: bytes) -> str:
return "ts=1234567890;h1=dummy_signature" return "ts=1234567890;h1=dummy_signature"
# ── Analytics mock ───────────────────────────────────────────
@pytest.fixture
def mock_analytics(monkeypatch):
"""Mock DuckDB analytics so dashboard tests run without a real DuckDB file.
Patches _conn to a sentinel (so routes skip the 'if _conn is None' guard),
then replaces every analytics query function with an async stub returning
deterministic data matching what the dashboard templates expect.
"""
from beanflows import analytics
monkeypatch.setattr(analytics, "_conn", object()) # truthy sentinel
_time_series = [
{"market_year": y, "production": 170000.0 + y * 100,
"exports": 80000.0, "imports": 5000.0,
"ending_stocks": 20000.0, "total_distribution": 160000.0}
for y in range(2021, 2026)
]
# Ensure latest production is 172,000 (2024 → 170000 + 2024*100 is too big;
# override the last element explicitly so the metric-card test matches).
_time_series[-1]["production"] = 172000.0
_top_producers = [
{"country_name": "Brazil", "country_code": "BR",
"market_year": 2025, "production": 63000.0},
{"country_name": "Vietnam", "country_code": "VN",
"market_year": 2025, "production": 30000.0},
]
_stu_trend = [
{"market_year": y, "stock_to_use_ratio_pct": 25.0}
for y in range(2021, 2026)
]
_balance = [
{"market_year": y, "production": 170000.0,
"total_distribution": 160000.0, "supply_demand_balance": 10000.0}
for y in range(2021, 2026)
]
_yoy_data = [
{"country_name": "Brazil", "country_code": "BR",
"market_year": 2025, "production": 63000.0, "production_yoy_pct": 2.5},
{"country_name": "Vietnam", "country_code": "VN",
"market_year": 2025, "production": 30000.0, "production_yoy_pct": -1.2},
]
async def _ts(*a, **kw): return _time_series
async def _top(*a, **kw): return _top_producers
async def _stu(*a, **kw): return _stu_trend
async def _bal(*a, **kw): return _balance
async def _yoy(*a, **kw): return _yoy_data
async def _cmp(*a, **kw): return []
monkeypatch.setattr(analytics, "get_global_time_series", _ts)
monkeypatch.setattr(analytics, "get_top_countries", _top)
monkeypatch.setattr(analytics, "get_stock_to_use_trend", _stu)
monkeypatch.setattr(analytics, "get_supply_demand_balance", _bal)
monkeypatch.setattr(analytics, "get_production_yoy_by_country", _yoy)
monkeypatch.setattr(analytics, "get_country_comparison", _cmp)

171
web/tests/test_analytics.py Normal file
View File

@@ -0,0 +1,171 @@
"""
Regression tests for analytics.py.
Bugs covered:
- Concurrent DuckDB queries via asyncio.gather returned empty/wrong results
because _conn.execute() is not thread-safe. Fixed by using _conn.cursor()
per asyncio.to_thread call (each cursor is independently usable from any
single thread).
- DuckDB normalizes unquoted column identifiers to lowercase; analytics
queries and callers must use lowercase names.
"""
import asyncio
import duckdb
import pytest
# ── Fixtures ────────────────────────────────────────────────────────────────
@pytest.fixture
def analytics_duckdb(tmp_path):
"""Temporary DuckDB with serving.commodity_metrics: 11 global rows + 5 country rows."""
db_path = str(tmp_path / "test.duckdb")
conn = duckdb.connect(db_path)
conn.execute("CREATE SCHEMA serving")
conn.execute(
"""
CREATE TABLE serving.commodity_metrics (
commodity_code INTEGER,
commodity_name TEXT,
country_code TEXT,
country_name TEXT,
market_year INTEGER,
ingest_date DATE,
production DOUBLE,
imports DOUBLE,
exports DOUBLE,
total_distribution DOUBLE,
ending_stocks DOUBLE,
net_supply DOUBLE,
trade_balance DOUBLE,
supply_demand_balance DOUBLE,
stock_to_use_ratio_pct DOUBLE,
production_yoy_pct DOUBLE
)
"""
)
# 11 global rows (20152025)
for year in range(2015, 2026):
conn.execute(
"""INSERT INTO serving.commodity_metrics VALUES
(711100, 'Coffee', NULL, 'Global', ?, '2025-01-01',
100.0, 10.0, 20.0, 90.0, 30.0, 90.0, 10.0, 10.0, 33.3, 1.0)""",
[year],
)
# 5 country rows for latest year
for code, name in [
("BR", "Brazil"), ("VN", "Vietnam"), ("CO", "Colombia"),
("ID", "Indonesia"), ("ET", "Ethiopia"),
]:
conn.execute(
"""INSERT INTO serving.commodity_metrics VALUES
(711100, 'Coffee', ?, ?, 2025, '2025-01-01',
50.0, 5.0, 10.0, 45.0, 15.0, 45.0, 5.0, 5.0, 33.3, 2.0)""",
[code, name],
)
conn.commit()
conn.close()
yield duckdb.connect(db_path, read_only=True)
@pytest.fixture(autouse=False)
def patched_analytics(analytics_duckdb, monkeypatch):
"""Patch analytics._conn with the temp DuckDB connection."""
from beanflows import analytics
monkeypatch.setattr(analytics, "_conn", analytics_duckdb)
yield analytics_duckdb
# ── Concurrency regression ───────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_concurrent_queries_all_return_data(patched_analytics):
"""
Regression: asyncio.gather fires analytics queries concurrently via
asyncio.to_thread. Using _conn.execute() from multiple threads simultaneously
corrupted internal cursor state — callers silently received 0 rows.
Fix: _query() obtains its own _conn.cursor() so each thread has an
independent execution context.
"""
from beanflows import analytics
ts, top, stu, bal, yoy = await asyncio.gather(
analytics.get_global_time_series(
711100, ["production", "exports", "imports", "ending_stocks", "total_distribution"]
),
analytics.get_top_countries(711100, "production", limit=10),
analytics.get_stock_to_use_trend(711100),
analytics.get_supply_demand_balance(711100),
analytics.get_production_yoy_by_country(711100, limit=15),
)
assert len(ts) == 11, f"time_series: expected 11, got {len(ts)}"
assert len(top) == 5, f"top_producers: expected 5, got {len(top)}"
assert len(stu) == 11, f"stu_trend: expected 11, got {len(stu)}"
assert len(bal) == 11, f"balance: expected 11, got {len(bal)}"
assert len(yoy) == 5, f"yoy: expected 5, got {len(yoy)}"
@pytest.mark.asyncio
async def test_repeated_concurrent_runs_are_stable(patched_analytics):
"""Concurrent queries should return consistent row counts across multiple runs."""
from beanflows import analytics
for _ in range(3):
ts, top = await asyncio.gather(
analytics.get_global_time_series(711100, ["production"]),
analytics.get_top_countries(711100, "production", limit=10),
)
assert len(ts) == 11
assert len(top) == 5
# ── Column name regression ───────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_result_column_names_are_lowercase(patched_analytics):
"""
Regression: DuckDB normalizes unquoted identifiers to lowercase in physical
tables. Templates and analytics callers must use lowercase column names.
"""
from beanflows import analytics
ts = await analytics.get_global_time_series(
711100, ["production", "exports", "total_distribution"]
)
assert ts, "Expected rows"
row = ts[0]
for col in ("market_year", "production", "exports", "total_distribution"):
assert col in row, f"Column '{col}' missing — DuckDB should return lowercase"
# Ensure no legacy mixed-case keys leaked through
for bad in ("Production", "Exports", "Total_Distribution", "Market_Year"):
assert bad not in row, f"Mixed-case key '{bad}' found — column casing regression"
@pytest.mark.asyncio
async def test_stu_trend_column_name_lowercase(patched_analytics):
"""stock_to_use_ratio_pct must be lowercase (was Stock_to_Use_Ratio_pct in SQL)."""
from beanflows import analytics
rows = await analytics.get_stock_to_use_trend(711100)
assert rows
assert "stock_to_use_ratio_pct" in rows[0]
assert "Stock_to_Use_Ratio_pct" not in rows[0]
# ── Global filter regression ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_global_time_series_excludes_country_rows(patched_analytics):
"""get_global_time_series must filter country_name = 'Global' only."""
from beanflows import analytics
rows = await analytics.get_global_time_series(711100, ["production"])
assert all(r["market_year"] in range(2015, 2026) for r in rows)
assert len(rows) == 11 # 11 global rows, 0 country rows

View File

@@ -59,7 +59,7 @@ async def test_dashboard_free_plan_no_csv_export(auth_client, mock_analytics):
response = await auth_client.get("/dashboard/") response = await auth_client.get("/dashboard/")
body = (await response.get_data(as_text=True)) body = (await response.get_data(as_text=True))
assert "CSV export available on Starter" in body assert "CSV export available on Trader" in body
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -75,5 +75,5 @@ async def test_countries_page_loads(auth_client, mock_analytics):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_countries_page_with_selection(auth_client, mock_analytics): async def test_countries_page_with_selection(auth_client, mock_analytics):
"""Country comparison with country params.""" """Country comparison with country params."""
response = await auth_client.get("/dashboard/countries?country=BR&country=VN&metric=Production") response = await auth_client.get("/dashboard/countries?country=BR&country=VN&metric=production")
assert response.status_code == 200 assert response.status_code == 200

View File

@@ -240,3 +240,97 @@ class TestImpersonation:
async with c.session_transaction() as sess: async with c.session_transaction() as sess:
assert sess["user_id"] == test_user["id"] assert sess["user_id"] == test_user["id"]
assert "admin_impersonating" not in sess assert "admin_impersonating" not in sess
# ════════════════════════════════════════════════════════════
# Admin auth flow regressions
# ════════════════════════════════════════════════════════════
class TestAdminAuthFlow:
"""Regression tests for the admin authentication flow.
Previously the admin panel used a password-based login (/admin/login).
That has been removed; the only way in is via the 'admin' role, granted
through ADMIN_EMAILS + auth/dev-login (dev) or the user_roles table (prod).
"""
async def test_admin_login_route_removed(self, client):
"""
Regression: /admin/login existed as a password-based route.
It has been removed; any request should 404.
"""
response = await client.get("/admin/login")
assert response.status_code == 404
async def test_admin_dev_login_route_removed(self, client):
"""
Regression: /admin/dev-login set session['is_admin']=True without
a user_id, making the user invisible to load_user and breaking
everything that checked g.user. It has been removed.
"""
response = await client.get("/admin/dev-login")
assert response.status_code == 404
async def test_admin_required_redirects_to_auth_login(self, auth_client, db):
"""
Regression: admin_required used to redirect to admin.login (the
password page). Now it redirects to auth.login.
"""
response = await auth_client.get("/admin/", follow_redirects=False)
assert response.status_code in (302, 303, 307)
location = response.headers.get("Location", "")
assert "/auth/login" in location, (
f"Expected redirect to /auth/login, got: {location}"
)
async def test_dev_login_grants_admin_role_for_admin_email(
self, app, db, monkeypatch
):
"""
Regression: auth/dev-login was not granting the admin role because
ADMIN_EMAILS was empty (missing from .env). The role must be granted
when the email matches ADMIN_EMAILS.
"""
from beanflows import core as _core
monkeypatch.setattr(_core.config, "ADMIN_EMAILS", ["admin@example.com"])
async with app.test_client() as c:
response = await c.get(
"/auth/dev-login?email=admin@example.com",
follow_redirects=False,
)
assert response.status_code in (302, 303, 307)
# user_id must be set in session
async with c.session_transaction() as sess:
user_id = sess.get("user_id")
assert user_id is not None
# admin role must exist in the DB
row = await core.fetch_one(
"SELECT role FROM user_roles WHERE user_id = ? AND role = 'admin'",
(user_id,),
)
assert row is not None, "admin role was not granted via dev-login"
async def test_dev_login_admin_redirects_to_admin_panel(
self, app, db, monkeypatch
):
"""
Regression: auth/dev-login redirected everyone to dashboard.index.
Admin-email logins should land directly on /admin/.
"""
from beanflows import core as _core
monkeypatch.setattr(_core.config, "ADMIN_EMAILS", ["admin@example.com"])
async with app.test_client() as c:
response = await c.get(
"/auth/dev-login?email=admin@example.com",
follow_redirects=False,
)
location = response.headers.get("Location", "")
assert "/admin" in location, (
f"Admin dev-login should redirect to /admin, got: {location}"
)