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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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("/")
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
74
web/src/beanflows/dev_seed.py
Normal file
74
web/src/beanflows/dev_seed.py
Normal 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())
|
||||||
@@ -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
171
web/tests/test_analytics.py
Normal 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 (2015–2025)
|
||||||
|
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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user