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:
parent
411094d7b8
commit
83ffa7dbf8
6 changed files with 246 additions and 22 deletions
|
|
@ -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 <vps>
|
||||
# then open http://localhost:8091
|
||||
```
|
||||
|
||||
- **Dev:** bound to **`127.0.0.1:8091`** on the host (loopback only, from
|
||||
`docker-compose.override.yml`). Open <http://localhost:8091>.
|
||||
- **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
|
||||
|
|
|
|||
96
app/cli.py
96
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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <vps>`.
|
||||
- "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
|
||||
|
|
|
|||
|
|
@ -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__
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue