"""Superadmin console — auth gate, read-only queries, and page wiring. Runs against an in-memory-ish sqlite file (same pattern as the other API tests): seed a handful of users + related rows, rebind app.db to the test engine, then drive both the query layer directly and the FastAPI app via TestClient. """ from __future__ import annotations import asyncio from datetime import timedelta def _seed(tmp_path): """Create schema + fixture rows; rebind app.db. Returns the factory.""" from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app import db as db_mod from app.db import Base, utcnow from app.models import ( EmailSend, PortfolioSync, Referral, StrategicLogFeedback, User, UserAcknowledgement, ) engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/admin.db") factory = async_sessionmaker(engine, expire_on_commit=False) db_mod._engine = engine db_mod._session_factory = factory now = utcnow() async def _go(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with factory() as s: # 1: free, referrer s.add(User(id=1, email="alice@x", tier="free", created_at=now, last_login_at=now, referral_code="ALICE")) # 2: paid subscriber via Stripe s.add(User(id=2, email="bob@x", tier="paid", created_at=now, last_login_at=now, stripe_customer_id="cus_1", stripe_subscription_id="sub_1")) # 3: free but credit-active, referred by alice, has sync/history s.add(User(id=3, email="carol@x", tier="free", created_at=now, credit_until=now + timedelta(days=30), referred_by_user_id=1)) await s.flush() s.add(PortfolioSync(user_id=3, outer_ciphertext=b"x", outer_nonce=b"y", version=1, created_at=now, updated_at=now)) s.add(EmailSend(user_id=3, kind="daily", sent_at=now, status="sent")) s.add(UserAcknowledgement(user_id=3, version=2, lang="en", accepted_at=now)) s.add(StrategicLogFeedback(log_id=1, user_id=3, vote="up", created_at=now, updated_at=now)) s.add(Referral(referrer_user_id=1, referred_user_id=3, created_at=now, converted_at=now)) await s.commit() asyncio.run(_go()) return factory # --- auth ------------------------------------------------------------------ def test_check_password(monkeypatch): from app.config import get_settings from admin import auth monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "s3cret") get_settings.cache_clear() assert auth.check_password("s3cret") is True assert auth.check_password("wrong") is False get_settings.cache_clear() def test_check_password_empty_denies(monkeypatch): from app.config import get_settings from admin import auth monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "") get_settings.cache_clear() # Empty password must reject everything — console closed by default. assert auth.check_password("") is False assert auth.check_password("anything") is False get_settings.cache_clear() def test_session_round_trip(monkeypatch): from app.config import get_settings from admin import auth monkeypatch.setenv("ADMIN_CONSOLE_SESSION_SECRET", "unit-test-secret") get_settings.cache_clear() token = auth.sign_session() assert auth.verify_session(token) is True assert auth.verify_session("garbage") is False get_settings.cache_clear() # --- queries --------------------------------------------------------------- def test_overview_stats(tmp_path): factory = _seed(tmp_path) from admin import queries async def _go(): async with factory() as s: return await queries.overview_stats(s) stats = asyncio.run(_go()) assert stats["total_users"] == 3 assert stats["free"] == 2 assert stats["paid"] == 1 # bob (tier paid) + carol (credit) both count as paid-active. assert stats["paid_active"] == 2 assert stats["credit_active"] == 1 assert stats["sync_enabled"] == 1 assert stats["referrals_total"] == 1 assert stats["referrals_converted"] == 1 def test_list_users(tmp_path): factory = _seed(tmp_path) from admin import queries async def _go(): async with factory() as s: return await queries.list_users(s) rows, total = asyncio.run(_go()) assert total == 3 by_id = {r["id"]: r for r in rows} assert by_id[2]["paid_active"] and by_id[2]["paid_source"] == "tier" assert by_id[2]["on_stripe"] is True assert by_id[3]["paid_active"] and by_id[3]["paid_source"] == "credit" assert by_id[3]["has_sync"] is True assert by_id[1]["referrals"] == 1 assert by_id[1]["paid_active"] is False def test_list_users_search(tmp_path): factory = _seed(tmp_path) from admin import queries async def _go(): async with factory() as s: return await queries.list_users(s, q="bob") rows, total = asyncio.run(_go()) assert total == 1 and rows[0]["email"] == "bob@x" def test_user_detail(tmp_path): factory = _seed(tmp_path) from admin import queries async def _go(): async with factory() as s: return await queries.user_detail(s, 3) d = asyncio.run(_go()) assert d is not None assert d["user"].email == "carol@x" assert d["paid"].active and d["paid"].source == "credit" assert d["referred_by"]["email"] == "alice@x" assert d["sync"]["enabled"] is True assert d["feedback"].get("up") == 1 assert len(d["emails"]) == 1 assert len(d["acks"]) == 1 def test_user_detail_missing(tmp_path): factory = _seed(tmp_path) from admin import queries async def _go(): async with factory() as s: return await queries.user_detail(s, 999) assert asyncio.run(_go()) is None def test_db_stats_sqlite_fallback(tmp_path): factory = _seed(tmp_path) from admin import queries async def _go(): async with factory() as s: return await queries.db_stats(s) stats = asyncio.run(_go()) names = {t["name"] for t in stats["tables"]} assert "users" in names users_row = next(t for t in stats["tables"] if t["name"] == "users") assert users_row["rows"] == 3 # --- app wiring ------------------------------------------------------------ def _client(tmp_path, monkeypatch): from fastapi.testclient import TestClient from app.config import get_settings _seed(tmp_path) monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "letmein") get_settings.cache_clear() from admin.main import app return TestClient(app, follow_redirects=False) def test_pages_require_login(tmp_path, monkeypatch): client = _client(tmp_path, monkeypatch) for path in ("/", "/users", "/users/1", "/db"): r = client.get(path) assert r.status_code == 303, path assert r.headers["location"] == "/login" get_settings_clear() def test_login_flow(tmp_path, monkeypatch): client = _client(tmp_path, monkeypatch) # Wrong password → 401, no cookie. r = client.post("/login", data={"password": "nope"}) assert r.status_code == 401 assert "admin_console_session" not in r.cookies # Right password → 303 to / with a session cookie. r = client.post("/login", data={"password": "letmein"}) assert r.status_code == 303 assert r.headers["location"] == "/" cookie = r.cookies.get("admin_console_session") assert cookie # Authenticated pages now load. r = client.get("/", cookies={"admin_console_session": cookie}) assert r.status_code == 200 assert "Overview" in r.text r = client.get("/users", cookies={"admin_console_session": cookie}) assert r.status_code == 200 assert "carol@x" in r.text r = client.get("/users/3", cookies={"admin_console_session": cookie}) assert r.status_code == 200 assert "carol@x" in r.text and "alice@x" in r.text r = client.get("/db", cookies={"admin_console_session": cookie}) assert r.status_code == 200 assert "users" in r.text get_settings_clear() def test_healthz(tmp_path, monkeypatch): client = _client(tmp_path, monkeypatch) r = client.get("/healthz") assert r.status_code == 200 and r.text == "ok" get_settings_clear() def get_settings_clear(): from app.config import get_settings get_settings.cache_clear()