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