From 83ffa7dbf8b8875c6c82d4761fa22c26c864e5f9 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Mon, 27 Jul 2026 18:54:26 +0200 Subject: [PATCH] admin: front console via NPM + add purge-test-users CLI Networking: the superadmin console now mirrors `app` instead of a loopback-only host port. Base compose drops the host port; the dev override binds 127.0.0.1:8091; the prod overlay joins the `intranet` network and listens on :80 with --proxy-headers so NPM can proxy it. CLI: add `purge-test-users` (dry-run by default, --commit to delete, --keep allow-list defaulting to the real accounts). Deletes child rows explicitly (DB-agnostic) plus email-keyed OTPs, so smoke-test signups that were pointed at prod can be cleaned repeatably instead of via ad-hoc SQL. Covered by 6 new tests. Co-Authored-By: Claude Opus 4.8 --- admin/README.md | 20 ++++--- app/cli.py | 96 ++++++++++++++++++++++++++++++ docker-compose.override.yml | 6 ++ docker-compose.prod.yml | 17 ++++-- docker-compose.yml | 14 ++--- tests/test_cli.py | 115 ++++++++++++++++++++++++++++++++++++ 6 files changed, 246 insertions(+), 22 deletions(-) diff --git a/admin/README.md b/admin/README.md index bd1685f..3f1213c 100644 --- a/admin/README.md +++ b/admin/README.md @@ -8,18 +8,20 @@ that **never** runs migrations or the scheduler and only ever issues `SELECT`s. ## Access model -- Bound to **`127.0.0.1:8091`** on the host — never exposed publicly, not on - the `intranet`/NPM network. Reach it over an SSH tunnel: - - ```sh - ssh -L 8091:localhost:8091 - # then open http://localhost:8091 - ``` - +- **Dev:** bound to **`127.0.0.1:8091`** on the host (loopback only, from + `docker-compose.override.yml`). Open . +- **Prod:** no host port. The container joins the `intranet` network and + listens on port 80, so **Nginx Proxy Manager** fronts it like the main app + (upstream `readmarkets-admin-1:80`). Point an NPM proxy host at it and, + ideally, add an NPM access list / basic-auth as a second layer. - Gated by a single shared password, `ADMIN_CONSOLE_PASSWORD` (in `.env`). Empty password ⇒ every login is refused (closed by default). The login sets a 12-hour signed cookie (`admin_console_session`). +> The console is now reachable on whatever public hostname NPM maps to it — +> it is no longer air-gapped behind an SSH tunnel. Keep `ADMIN_CONSOLE_PASSWORD` +> strong and prefer adding an NPM access rule in front of it. + ## Pages - `/` — overview: totals, tier split, paid-active, signups 7/30d, sync count, @@ -41,6 +43,8 @@ overlay: # 1. set ADMIN_CONSOLE_PASSWORD in .env # 2. build + create just the admin container (leaves app/scheduler/db running) docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build admin +# 3. in Nginx Proxy Manager: add a proxy host → forward to readmarkets-admin-1 +# port 80 (both containers are on the `intranet` network). ``` ## Tests diff --git a/app/cli.py b/app/cli.py index c780f0b..8e546e7 100644 --- a/app/cli.py +++ b/app/cli.py @@ -146,6 +146,90 @@ async def send_test_digest(email: str, kind: str) -> int: return 0 +# The genuinely-real accounts. Everything else in the users table is a +# leftover from smoke/E2E signup tests that were pointed at the live site +# (OTP delivered to the @gilest.ro catch-all). Kept here so the purge is an +# explicit allow-list — deny by default — rather than pattern-matching on +# test prefixes that a future test run might not follow. +DEFAULT_KEEP_EMAILS = ( + "giorgio@gilest.ro", + "giorgio.gilestro@gmail.com", + "ilariodamato@hotmail.com", +) + + +async def purge_test_users(keep: list[str], commit: bool) -> int: + """Delete every user whose email is not in `keep`, cascading to child + rows. Dry-run by default: prints exactly what would go and changes + nothing unless `commit` is True. + + Deletes child rows explicitly (rather than leaning on DB-level ON DELETE + CASCADE) so behaviour is identical on MariaDB and the sqlite test DB. + """ + from sqlalchemy import delete, or_, update + + from app.models import ( + EmailOTP, EmailSend, PortfolioSync, Referral, + StrategicLogFeedback, User, UserAcknowledgement, + ) + + keep_set = {e.strip().lower() for e in keep if e.strip()} + factory = get_session_factory() + async with factory() as session: + rows = (await session.execute( + select(User.id, User.email, User.tier, User.created_at) + .order_by(User.id) + )).all() + victims = [r for r in rows if (r.email or "").lower() not in keep_set] + kept = [r for r in rows if (r.email or "").lower() in keep_set] + + print(f"users total: {len(rows)} keep: {len(kept)} " + f"to delete: {len(victims)}") + print("\nKEEP:") + for r in kept: + print(f" [{r.id}] {r.email} ({r.tier})") + print("\nDELETE:" if victims else "\nDELETE: (none)") + for r in victims: + print(f" [{r.id}] {r.email} ({r.tier}) {r.created_at}") + + if not victims: + print("\nnothing to do.") + return 0 + if not commit: + print(f"\nDRY RUN — nothing deleted. " + f"Re-run with --commit to remove these {len(victims)} " + f"account(s).") + return 0 + + ids = [r.id for r in victims] + emails = [r.email for r in victims if r.email] + # Child rows first (explicit; order matters without cascade). + await session.execute(delete(StrategicLogFeedback) + .where(StrategicLogFeedback.user_id.in_(ids))) + await session.execute(delete(EmailSend) + .where(EmailSend.user_id.in_(ids))) + await session.execute(delete(PortfolioSync) + .where(PortfolioSync.user_id.in_(ids))) + await session.execute(delete(UserAcknowledgement) + .where(UserAcknowledgement.user_id.in_(ids))) + await session.execute(delete(Referral).where(or_( + Referral.referrer_user_id.in_(ids), + Referral.referred_user_id.in_(ids), + ))) + # Self-referential FK on survivors that pointed at a victim. + await session.execute(update(User) + .where(User.referred_by_user_id.in_(ids)) + .values(referred_by_user_id=None)) + # OTPs are keyed by email, not user_id — no FK to cascade. + if emails: + await session.execute(delete(EmailOTP) + .where(EmailOTP.email.in_(emails))) + result = await session.execute(delete(User).where(User.id.in_(ids))) + await session.commit() + print(f"\ndeleted {result.rowcount} user(s) and their child rows.") + return 0 + + def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="app.cli", description="Cassandra admin CLI") sub = p.add_subparsers(dest="cmd", required=True) @@ -165,6 +249,15 @@ def build_parser() -> argparse.ArgumentParser: t.add_argument("email") t.add_argument("kind", choices=("daily", "weekly")) + pu = sub.add_parser( + "purge-test-users", + help="Delete all users except an allow-list (dry-run unless --commit)") + pu.add_argument( + "--keep", action="append", metavar="EMAIL", default=None, + help="Email to keep; repeatable. Defaults to the known real accounts.") + pu.add_argument("--commit", action="store_true", + help="Actually delete. Without this it is a dry run.") + return p @@ -181,6 +274,9 @@ async def _dispatch(args) -> int: return await show_status(args.email) if args.cmd == "send-test-digest": return await send_test_digest(args.email, args.kind) + if args.cmd == "purge-test-users": + keep = args.keep if args.keep else list(DEFAULT_KEEP_EMAILS) + return await purge_test_users(keep, args.commit) return 2 finally: await get_engine().dispose() diff --git a/docker-compose.override.yml b/docker-compose.override.yml index d83d0e8..9380856 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -15,3 +15,9 @@ services: - ./app:/app/app ports: - "${CASSANDRA_PORT:-8000}:8000" + + admin: + # Dev: reach the console at http://localhost:8091 (loopback only). Prod + # drops this and fronts it with NPM instead (docker-compose.prod.yml). + ports: + - "127.0.0.1:${CASSANDRA_ADMIN_PORT:-8091}:8000" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ac3f6ec..c077428 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -45,10 +45,19 @@ services: REDIS_URL: redis://readmarkets-redis-1:6379/0 admin: - # Same DNS-collision reasoning as app/scheduler: use the project-prefixed - # container name for the DB. The console stays OFF the intranet network — - # it is internal-only (127.0.0.1:8091 host port from the base file), so it - # never needs to be reachable by NPM. + # Fronted by NPM like `app`: listen on 80 and join the `intranet` network + # so the proxy can reach it as `readmarkets-admin-1:80`. No host port. + # --proxy-headers so redirect/asset URLs honour X-Forwarded-Proto from NPM. + # Still gated by ADMIN_CONSOLE_PASSWORD; add an NPM access rule in front for + # a second layer. Project-prefixed DB name avoids the shared-network `db` + # DNS collision (same reason as app/scheduler). + command: ["uvicorn", "admin.main:app", "--host", "0.0.0.0", "--port", "80", + "--workers", "1", "--proxy-headers", "--forwarded-allow-ips=*"] + expose: + - "80" + networks: + - default + - intranet environment: DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra} diff --git a/docker-compose.yml b/docker-compose.yml index a25b80d..598d704 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,8 +79,10 @@ services: # Superadmin console — independent read-only operator dashboard. Same # image (reuses app.db/app.models) but runs admin.main:app instead of the - # public app, and NEVER runs migrations. Bound to 127.0.0.1 only: it is - # reached over an SSH tunnel, never exposed publicly (no intranet/NPM). + # public app, and NEVER runs migrations. No host port here, mirroring `app`: + # dev adds a loopback port (docker-compose.override.yml) and prod joins the + # `intranet` network so NPM can proxy it (docker-compose.prod.yml). Still + # gated by ADMIN_CONSOLE_PASSWORD. admin: build: . restart: unless-stopped @@ -92,14 +94,6 @@ services: - ./config:/app/config:ro - ./app:/app/app - ./admin:/app/admin - ports: - # Host-loopback only — access via `ssh -L 8091:localhost:8091 `. - - "127.0.0.1:8091:8000" - healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"] - interval: 30s - timeout: 5s - retries: 3 depends_on: db: condition: service_healthy diff --git a/tests/test_cli.py b/tests/test_cli.py index 616bed9..b3b9dfe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,6 +6,8 @@ container. The parser-level tests are enough to catch the common shapes: bad args, missing args, unknown sub-command.""" from __future__ import annotations +import asyncio + import pytest from app.cli import build_parser @@ -47,3 +49,116 @@ def test_unknown_command_rejected(): def test_no_command_rejected(): with pytest.raises(SystemExit): build_parser().parse_args([]) + + +# --- purge-test-users ------------------------------------------------------ + +def test_purge_parses_dry_run_default(): + args = build_parser().parse_args(["purge-test-users"]) + assert args.cmd == "purge-test-users" + assert args.commit is False + assert args.keep is None + + +def test_purge_parses_keep_and_commit(): + args = build_parser().parse_args( + ["purge-test-users", "--keep", "a@x", "--keep", "b@x", "--commit"]) + assert args.keep == ["a@x", "b@x"] + assert args.commit is True + + +def _seed_users(tmp_path): + """Two keepers + one throwaway with child rows across every table.""" + 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 ( + EmailOTP, EmailSend, PortfolioSync, Referral, + StrategicLogFeedback, User, UserAcknowledgement, + ) + + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/cli.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: + s.add(User(id=1, email="keep@real.com", tier="paid", created_at=now)) + s.add(User(id=2, email="also@real.com", tier="free", created_at=now, + referred_by_user_id=3)) # referred by a victim + s.add(User(id=3, email="junk-123@gilest.ro", tier="free", + created_at=now)) + await s.flush() + s.add(EmailSend(user_id=3, kind="daily", sent_at=now, status="sent")) + s.add(PortfolioSync(user_id=3, outer_ciphertext=b"x", + outer_nonce=b"y", version=1, + created_at=now, updated_at=now)) + s.add(UserAcknowledgement(user_id=3, version=1, 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)) + s.add(EmailOTP(email="junk-123@gilest.ro", code_hash="h", + created_at=now, expires_at=now)) + await s.commit() + + asyncio.run(_go()) + return factory + + +def _count(factory, model): + from sqlalchemy import func, select + + async def _go(): + async with factory() as s: + return (await s.execute(select(func.count()).select_from(model))).scalar() + + return asyncio.run(_go()) + + +def test_purge_dry_run_deletes_nothing(tmp_path): + from app.cli import purge_test_users + from app.models import User + + factory = _seed_users(tmp_path) + rc = asyncio.run(purge_test_users(["keep@real.com", "also@real.com"], + commit=False)) + assert rc == 0 + assert _count(factory, User) == 3 # untouched + + +def test_purge_commit_removes_victim_and_children(tmp_path): + from sqlalchemy import select + + from app.cli import purge_test_users + from app.models import ( + EmailOTP, EmailSend, PortfolioSync, Referral, + StrategicLogFeedback, User, UserAcknowledgement, + ) + + factory = _seed_users(tmp_path) + rc = asyncio.run(purge_test_users(["keep@real.com", "also@real.com"], + commit=True)) + assert rc == 0 + # Only the two keepers remain. + async def _remaining(): + async with factory() as s: + emails = (await s.execute(select(User.email).order_by(User.id))).scalars().all() + survivor = (await s.execute( + select(User.referred_by_user_id).where(User.id == 2))).scalar() + return emails, survivor + + emails, survivor = asyncio.run(_remaining()) + assert emails == ["keep@real.com", "also@real.com"] + # Self-ref FK to the deleted user was nulled, not left dangling. + assert survivor is None + # Every child table row for the victim is gone. + for model in (EmailSend, PortfolioSync, UserAcknowledgement, + StrategicLogFeedback, Referral, EmailOTP): + assert _count(factory, model) == 0, model.__name__