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

@ -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__