feat(admin): replace dedicated pages with a generic table editor at /admin
This commit is contained in:
@@ -10,49 +10,9 @@ def users_db_path(monkeypatch, tmp_path):
|
||||
|
||||
db_path = tmp_path / "users.test.sqlite"
|
||||
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
||||
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
|
||||
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
||||
reset_conn_for_tests()
|
||||
auth_db.init_schema()
|
||||
sub_db.init_schema()
|
||||
migrations.apply_pending()
|
||||
yield db_path
|
||||
reset_conn_for_tests()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_app(users_db_path, monkeypatch):
|
||||
from flask import Flask
|
||||
|
||||
from src.auth.setup import init_auth
|
||||
from src.subscriptions.setup import init_subscriptions
|
||||
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
|
||||
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
|
||||
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
||||
monkeypatch.setenv("FRISBII_WEBHOOK_SECRET", "s3cr3t")
|
||||
flask_app = Flask(__name__)
|
||||
flask_app.config["WTF_CSRF_ENABLED"] = False
|
||||
init_auth(flask_app)
|
||||
init_subscriptions(flask_app)
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(admin_app):
|
||||
return admin_app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logged_in_admin_client(admin_app, monkeypatch):
|
||||
from src.auth import db as auth_db
|
||||
|
||||
monkeypatch.setenv("ADMIN_EMAIL", "admin@ex.fr")
|
||||
uid = auth_db.create_user("admin@ex.fr", "hash")
|
||||
auth_db.set_email_verified(uid)
|
||||
client = admin_app.test_client()
|
||||
with client.session_transaction() as sess:
|
||||
sess["_user_id"] = str(uid)
|
||||
sess["_fresh"] = True
|
||||
return client, uid
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from dash.testing.composite import DashComposite
|
||||
from selenium.webdriver.support.ui import Select
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from src.auth import db as auth_db
|
||||
from src.subscriptions import db as sub_db
|
||||
|
||||
PASSWORD = "s3cretpass!"
|
||||
|
||||
|
||||
def _unique_email(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}@ex.fr"
|
||||
|
||||
|
||||
def _make_verified_user(email: str) -> int:
|
||||
auth_db.init_schema()
|
||||
uid = auth_db.create_user(email, generate_password_hash(PASSWORD))
|
||||
auth_db.set_email_verified(uid)
|
||||
return uid
|
||||
|
||||
|
||||
def _cleanup_user(user_id: int) -> None:
|
||||
conn = auth_db.get_conn()
|
||||
conn.execute("DELETE FROM admin_actions WHERE target_user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM subscriptions WHERE user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM subscriber_state WHERE user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
# tests/users.test.sqlite is committed to git and shared by the whole
|
||||
# Selenium session (USERS_DB_PATH is set globally in pyproject.toml).
|
||||
# Row DELETEs alone leave the file byte-diffed: AUTOINCREMENT tables
|
||||
# record their high-water mark in sqlite_sequence, and that table is
|
||||
# never rolled back by DELETE (by SQLite design). Reset it explicitly so
|
||||
# a full test run leaves the committed file untouched (`git status`
|
||||
# clean), matching the pre-existing convention for this file where
|
||||
# users/subscriptions already sit at seq=0.
|
||||
conn.execute(
|
||||
"UPDATE sqlite_sequence SET seq = 0 "
|
||||
"WHERE name IN ('users', 'subscriptions', 'admin_actions')"
|
||||
)
|
||||
|
||||
|
||||
def _login(dash_duo: DashComposite, email: str):
|
||||
dash_duo.driver.get(dash_duo.server_url + "/connexion")
|
||||
dash_duo.wait_for_element("input[name=email]", timeout=8).send_keys(email)
|
||||
dash_duo.driver.find_element("css selector", "input[name=password]").send_keys(
|
||||
PASSWORD
|
||||
)
|
||||
dash_duo.driver.find_element("css selector", "button[type=submit]").click()
|
||||
|
||||
|
||||
def test_admin_anonymous_gets_404(dash_duo: DashComposite):
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.driver.get(dash_duo.server_url + "/admin")
|
||||
dash_duo.wait_for_text_to_equal("#admin-404-heading", "404", timeout=8)
|
||||
|
||||
|
||||
def test_admin_non_admin_gets_404(dash_duo: DashComposite, monkeypatch):
|
||||
from src.app import app
|
||||
|
||||
monkeypatch.setenv("ADMIN_EMAIL", "admin-only@ex.fr")
|
||||
email = _unique_email("regular")
|
||||
uid = _make_verified_user(email)
|
||||
try:
|
||||
dash_duo.start_server(app)
|
||||
_login(dash_duo, email)
|
||||
# Confirm login actually succeeded before relying on the admin guard.
|
||||
# A failed login (bad credentials, unverified email, ...) redirects
|
||||
# back to /connexion rather than raising, leaving the session
|
||||
# anonymous — which would also yield a 404 at /admin and make this
|
||||
# test indistinguishable from test_admin_anonymous_gets_404. Since
|
||||
# this user has no subscription, a successful login redirects to
|
||||
# /compte/abonnement (see _post_login_url in src/auth/routes.py).
|
||||
dash_duo.wait_for_text_to_equal("h2", "Abonnement", timeout=8)
|
||||
assert "/connexion" not in dash_duo.driver.current_url
|
||||
|
||||
dash_duo.driver.get(dash_duo.server_url + "/admin")
|
||||
dash_duo.wait_for_text_to_equal("#admin-404-heading", "404", timeout=8)
|
||||
finally:
|
||||
_cleanup_user(uid)
|
||||
|
||||
|
||||
def test_admin_full_flow(dash_duo: DashComposite, monkeypatch):
|
||||
from src.app import app
|
||||
|
||||
admin_email = _unique_email("admin")
|
||||
monkeypatch.setenv("ADMIN_EMAIL", admin_email)
|
||||
admin_uid = _make_verified_user(admin_email)
|
||||
|
||||
target_email = _unique_email("target")
|
||||
target_uid = _make_verified_user(target_email)
|
||||
sub_db.init_schema()
|
||||
_handle, sub_id = sub_db.create_pending(target_uid, "cust-e2e", "simple", 20.0)
|
||||
sub_db.set_status(sub_id, "active")
|
||||
|
||||
try:
|
||||
dash_duo.start_server(app)
|
||||
_login(dash_duo, admin_email)
|
||||
|
||||
dash_duo.driver.get(dash_duo.server_url + "/admin")
|
||||
dash_duo.wait_for_text_to_equal("h2", "Panneau admin", timeout=8)
|
||||
assert target_email in dash_duo.driver.page_source
|
||||
|
||||
dash_duo.driver.get(f"{dash_duo.server_url}/admin/user/{target_uid}")
|
||||
dash_duo.wait_for_text_to_equal("h2", "Panneau admin", timeout=8)
|
||||
assert target_email in dash_duo.driver.page_source
|
||||
|
||||
select = Select(
|
||||
dash_duo.driver.find_element("css selector", "select[name=status]")
|
||||
)
|
||||
select.select_by_value("cancelled")
|
||||
dash_duo.driver.find_element("css selector", "button[type=submit]").click()
|
||||
|
||||
dash_duo.wait_for_text_to_equal(
|
||||
".alert-success", "Statut de l'abonnement mis à jour.", timeout=8
|
||||
)
|
||||
assert "cancelled" in dash_duo.driver.page_source
|
||||
|
||||
dash_duo.driver.get(dash_duo.server_url + "/admin/journal")
|
||||
dash_duo.wait_for_text_to_equal("h2", "Journal des actions admin", timeout=8)
|
||||
assert "subscription_status_change" in dash_duo.driver.page_source
|
||||
assert "active → cancelled" in dash_duo.driver.page_source
|
||||
finally:
|
||||
_cleanup_user(target_uid)
|
||||
_cleanup_user(admin_uid)
|
||||
@@ -1,76 +0,0 @@
|
||||
import itertools
|
||||
|
||||
from src.auth import db as auth_db
|
||||
from src.subscriptions import db as sub_db
|
||||
|
||||
_email_counter = itertools.count(1)
|
||||
|
||||
|
||||
def _make_target_with_subscription():
|
||||
uid = auth_db.create_user(f"target{next(_email_counter)}@ex.fr", "hash")
|
||||
_handle, sub_id = sub_db.create_pending(uid, "cust-1", "simple")
|
||||
return uid, sub_id
|
||||
|
||||
|
||||
def test_subscription_status_requires_admin(admin_client):
|
||||
resp = admin_client.post(
|
||||
"/admin/actions/subscription-status",
|
||||
data={"user_id": "1", "subscription_id": "1", "status": "active"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_subscription_status_rejects_invalid_status(logged_in_admin_client):
|
||||
client, _admin_uid = logged_in_admin_client
|
||||
uid, sub_id = _make_target_with_subscription()
|
||||
|
||||
resp = client.post(
|
||||
"/admin/actions/subscription-status",
|
||||
data={"user_id": str(uid), "subscription_id": str(sub_id), "status": "bogus"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"] == f"/admin/user/{uid}?error=invalid_status"
|
||||
assert sub_db.get_current(uid)["status"] == "pending"
|
||||
|
||||
|
||||
def test_subscription_status_rejects_mismatched_subscription(logged_in_admin_client):
|
||||
client, _admin_uid = logged_in_admin_client
|
||||
uid, _sub_id = _make_target_with_subscription()
|
||||
other_uid, other_sub_id = _make_target_with_subscription()
|
||||
|
||||
resp = client.post(
|
||||
"/admin/actions/subscription-status",
|
||||
data={
|
||||
"user_id": str(uid),
|
||||
"subscription_id": str(other_sub_id),
|
||||
"status": "active",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"] == f"/admin/user/{uid}?error=invalid_status"
|
||||
assert sub_db.get_current(other_uid)["status"] == "pending"
|
||||
|
||||
|
||||
def test_subscription_status_success_updates_and_logs(logged_in_admin_client):
|
||||
from src.admin.db import list_actions
|
||||
|
||||
client, _admin_uid = logged_in_admin_client
|
||||
uid, sub_id = _make_target_with_subscription()
|
||||
|
||||
resp = client.post(
|
||||
"/admin/actions/subscription-status",
|
||||
data={"user_id": str(uid), "subscription_id": str(sub_id), "status": "active"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"] == f"/admin/user/{uid}?status_changed=1"
|
||||
assert sub_db.get_current(uid)["status"] == "active"
|
||||
|
||||
actions = list_actions()
|
||||
assert len(actions) == 1
|
||||
assert actions[0]["action"] == "subscription_status_change"
|
||||
assert actions[0]["target_user_id"] == uid
|
||||
assert actions[0]["details"] == "pending → active"
|
||||
assert actions[0]["admin_email"] == "admin@ex.fr"
|
||||
Reference in New Issue
Block a user