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 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-07-27 18:54:26 +02:00
parent 411094d7b8
commit 83ffa7dbf8
6 changed files with 246 additions and 22 deletions

View file

@ -8,18 +8,20 @@ that **never** runs migrations or the scheduler and only ever issues `SELECT`s.
## Access model ## Access model
- Bound to **`127.0.0.1:8091`** on the host — never exposed publicly, not on - **Dev:** bound to **`127.0.0.1:8091`** on the host (loopback only, from
the `intranet`/NPM network. Reach it over an SSH tunnel: `docker-compose.override.yml`). Open <http://localhost:8091>.
- **Prod:** no host port. The container joins the `intranet` network and
```sh listens on port 80, so **Nginx Proxy Manager** fronts it like the main app
ssh -L 8091:localhost:8091 <vps> (upstream `readmarkets-admin-1:80`). Point an NPM proxy host at it and,
# then open http://localhost:8091 ideally, add an NPM access list / basic-auth as a second layer.
```
- Gated by a single shared password, `ADMIN_CONSOLE_PASSWORD` (in `.env`). - Gated by a single shared password, `ADMIN_CONSOLE_PASSWORD` (in `.env`).
Empty password ⇒ every login is refused (closed by default). The login sets Empty password ⇒ every login is refused (closed by default). The login sets
a 12-hour signed cookie (`admin_console_session`). 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 ## Pages
- `/` — overview: totals, tier split, paid-active, signups 7/30d, sync count, - `/` — overview: totals, tier split, paid-active, signups 7/30d, sync count,
@ -41,6 +43,8 @@ overlay:
# 1. set ADMIN_CONSOLE_PASSWORD in .env # 1. set ADMIN_CONSOLE_PASSWORD in .env
# 2. build + create just the admin container (leaves app/scheduler/db running) # 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 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 ## Tests

View file

@ -146,6 +146,90 @@ async def send_test_digest(email: str, kind: str) -> int:
return 0 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: def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="app.cli", description="Cassandra admin CLI") p = argparse.ArgumentParser(prog="app.cli", description="Cassandra admin CLI")
sub = p.add_subparsers(dest="cmd", required=True) sub = p.add_subparsers(dest="cmd", required=True)
@ -165,6 +249,15 @@ def build_parser() -> argparse.ArgumentParser:
t.add_argument("email") t.add_argument("email")
t.add_argument("kind", choices=("daily", "weekly")) 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 return p
@ -181,6 +274,9 @@ async def _dispatch(args) -> int:
return await show_status(args.email) return await show_status(args.email)
if args.cmd == "send-test-digest": if args.cmd == "send-test-digest":
return await send_test_digest(args.email, args.kind) 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 return 2
finally: finally:
await get_engine().dispose() await get_engine().dispose()

View file

@ -15,3 +15,9 @@ services:
- ./app:/app/app - ./app:/app/app
ports: ports:
- "${CASSANDRA_PORT:-8000}:8000" - "${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"

View file

@ -45,10 +45,19 @@ services:
REDIS_URL: redis://readmarkets-redis-1:6379/0 REDIS_URL: redis://readmarkets-redis-1:6379/0
admin: admin:
# Same DNS-collision reasoning as app/scheduler: use the project-prefixed # Fronted by NPM like `app`: listen on 80 and join the `intranet` network
# container name for the DB. The console stays OFF the intranet network — # so the proxy can reach it as `readmarkets-admin-1:80`. No host port.
# it is internal-only (127.0.0.1:8091 host port from the base file), so it # --proxy-headers so redirect/asset URLs honour X-Forwarded-Proto from NPM.
# never needs to be reachable by 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: environment:
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra} DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra}

View file

@ -79,8 +79,10 @@ services:
# Superadmin console — independent read-only operator dashboard. Same # Superadmin console — independent read-only operator dashboard. Same
# image (reuses app.db/app.models) but runs admin.main:app instead of the # 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 # public app, and NEVER runs migrations. No host port here, mirroring `app`:
# reached over an SSH tunnel, never exposed publicly (no intranet/NPM). # 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: admin:
build: . build: .
restart: unless-stopped restart: unless-stopped
@ -92,14 +94,6 @@ services:
- ./config:/app/config:ro - ./config:/app/config:ro
- ./app:/app/app - ./app:/app/app
- ./admin:/app/admin - ./admin:/app/admin
ports:
# Host-loopback only — access via `ssh -L 8091:localhost:8091 <vps>`.
- "127.0.0.1:8091:8000"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
interval: 30s
timeout: 5s
retries: 3
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View file

@ -6,6 +6,8 @@ container. The parser-level tests are enough to catch the common
shapes: bad args, missing args, unknown sub-command.""" shapes: bad args, missing args, unknown sub-command."""
from __future__ import annotations from __future__ import annotations
import asyncio
import pytest import pytest
from app.cli import build_parser from app.cli import build_parser
@ -47,3 +49,116 @@ def test_unknown_command_rejected():
def test_no_command_rejected(): def test_no_command_rejected():
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
build_parser().parse_args([]) 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__