import importlib import os import re import sys import types import uuid import pytest from fastapi import HTTPException from fastapi.testclient import TestClient from sqlalchemy import delete, select, text, update from sqlalchemy.orm import Session os.environ["JWT_SECRET"] = "test-secret-must-be-32-characters!" os.environ["API_PAYLOAD_ENCRYPT"] = "false" PASSWORD_RULE = "密码至少 8 位,且同时包含字母和数字" LOGIN_FAILURE = "用户名或密码错误" KEEP_SELF = "不能停用或注销当前登录用户" KEEP_FULL_ADMIN = "至少保留一名拥有全部系统菜单的正常用户" def _install_stub(module_name: str, **attrs: object) -> None: try: module = importlib.import_module(module_name) except Exception: sys.modules.pop(module_name, None) module = types.ModuleType(module_name) sys.modules[module_name] = module for key, value in attrs.items(): setattr(module, key, value) return for key, value in attrs.items(): if not hasattr(module, key): setattr(module, key, value) def _encrypt_text(value: str) -> str: return f"enc:{value}" def _consume_captcha(captcha_id: str, code: str) -> bool: return False def _is_currently_locked(user) -> bool: return False def _record_failure(db, user) -> None: user.failed_login_count = int(user.failed_login_count or 0) + 1 def _clear_failures(db, user) -> None: user.failed_login_count = 0 user.locked_until = None _install_stub("app.core.field_crypto", encrypt_text=_encrypt_text) _install_stub("app.services.captcha", consume_captcha=_consume_captcha) _install_stub( "app.services.login_guard", is_currently_locked=_is_currently_locked, record_failure=_record_failure, clear_failures=_clear_failures, ) from app.api.admin.rbac import public_router, router from app.commands.create_superuser import seed_superuser from app.core.config import get_settings from app.core.masking import mask_mobile from app.core.security import create_access_token from app.db import Base, get_db, get_engine from app.main import create_app from app.models.rbac import AdminResource, AdminRole, AdminRoleResource, AdminUser, AdminUserRole, utcnow from app.services.rbac import count_full_admins, preserve_full_admin get_settings.cache_clear() def _safe_error(exc: Exception) -> str: raw = str(exc).splitlines()[0] if str(exc) else "" raw = re.sub(r"://\S+", "://***", raw) raw = re.sub(r"password=\S+", "password=***", raw, flags=re.I) return f"{exc.__class__.__name__}: {raw[:180]}" def _database_url() -> str: get_settings.cache_clear() return get_settings().database_url.strip() def _suffix(prefix: str) -> str: return f"{prefix}_{uuid.uuid4().hex[:10]}" def _staff_headers(subject: str = "1") -> dict[str, str]: token = create_access_token(subject, "staff") return {"Authorization": f"Bearer {token}"} def _mount(app) -> None: paths = {getattr(route, "path", "") for route in app.routes} if "/api/admin/users" not in paths: app.include_router(public_router, prefix="/api/admin") app.include_router(router, prefix="/api/admin") def _committed(username: str) -> bool: other = Session(bind=get_engine()) try: found = other.scalar(select(AdminUser.id).where(AdminUser.username == username)) return found is not None finally: other.close() def _delete_committed_user(username: str) -> None: other = Session(bind=get_engine()) try: row = other.scalar(select(AdminUser).where(AdminUser.username == username)) if row is None: return other.execute(delete(AdminUserRole).where(AdminUserRole.user_id == row.id)) other.delete(row) other.commit() finally: other.close() def _assert_isolated(connection, username: str) -> None: probe = Session( bind=connection, autoflush=False, expire_on_commit=False, join_transaction_mode="create_savepoint", ) now = utcnow() probe.add( AdminUser( username=username, password_hash="probe", display_name="probe", mobile_mask="", status="active", failed_login_count=0, created_at=now, updated_at=now, ) ) probe.commit() probe.close() if _committed(username): _delete_committed_user(username) pytest.fail("测试事务没有隔离,已停止,避免改动已有数据") @pytest.fixture def client(): if not _database_url(): pytest.skip("DATABASE_URL 未配置") os.environ["API_PAYLOAD_ENCRYPT"] = "false" get_settings.cache_clear() try: engine = get_engine() with engine.connect() as probe: probe.execute(text("SELECT 1")) Base.metadata.create_all(bind=engine) except Exception as exc: pytest.skip(f"数据库不可用:{_safe_error(exc)}") connection = engine.connect() transaction = connection.begin() probe_name = _suffix("probe") try: _assert_isolated(connection, probe_name) app = create_app() _mount(app) app.state.connection = connection def override_db(): db = Session( bind=connection, autoflush=False, expire_on_commit=False, join_transaction_mode="create_savepoint", ) try: yield db finally: db.close() app.dependency_overrides[get_db] = override_db with TestClient(app) as test_client: yield test_client finally: try: transaction.rollback() except Exception: pass connection.close() def _walk(nodes: list[dict]): for node in nodes: yield node yield from _walk(node.get("children") or []) def _no_password(payload) -> None: if isinstance(payload, dict): for key, value in payload.items(): assert "password" not in key.lower() assert key not in {"mobileCipher", "mobile_cipher", "password_hash"} _no_password(value) elif isinstance(payload, list): for item in payload: _no_password(item) def test_users_requires_staff_token(client: TestClient): missing = client.get("/api/admin/users") assert missing.status_code == 401 member = create_access_token("member-1", "member") rejected = client.get("/api/admin/users", headers={"Authorization": f"Bearer {member}"}) assert rejected.status_code == 403 def test_weak_password_rejected(client: TestClient): headers = _staff_headers() username = _suffix("weak") response = client.post( "/api/admin/users", headers=headers, json={ "username": username, "password": "abcdefgh", "displayName": "弱密码", "roleIds": [], }, ) assert response.status_code == 400 assert response.json()["detail"] == PASSWORD_RULE digits = client.post( "/api/admin/users", headers=headers, json={ "username": username, "password": "12345678", "displayName": "弱密码", "roleIds": [], }, ) assert digits.status_code == 400 assert digits.json()["detail"] == PASSWORD_RULE def test_cannot_cancel_self(client: TestClient): headers = _staff_headers("0") username = _suffix("self") created = client.post( "/api/admin/users", headers=headers, json={ "username": username, "password": "Abcdef12", "displayName": "本人", "roleIds": [], }, ) assert created.status_code == 200 user_id = created.json()["id"] own = _staff_headers(str(user_id)) cancelled = client.put( f"/api/admin/users/{user_id}", headers=own, json={"displayName": "本人", "status": "cancelled", "roleIds": []}, ) assert cancelled.status_code == 400 assert cancelled.json()["detail"] == KEEP_SELF listed = client.get("/api/admin/users", headers=headers, params={"username": username}) assert listed.status_code == 200 record = next(item for item in listed.json()["records"] if item["username"] == username) assert record["status"] == "active" def test_user_list_masks_mobile_and_hides_password(client: TestClient, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("app.services.rbac.encrypt_text", lambda value: "cipher-text") headers = _staff_headers() username = _suffix("mobile") mobile = "13812348000" password = "Abcdef12" created = client.post( "/api/admin/users", headers=headers, json={ "username": username, "password": password, "displayName": "脱敏", "mobile": mobile, "roleIds": [], }, ) assert created.status_code == 200 created_body = created.json() assert created_body["mobileMask"] == mask_mobile(mobile) == "138****8000" _no_password(created_body) assert mobile not in created.text assert password not in created.text assert "cipher-text" not in created.text listed = client.get("/api/admin/users", headers=headers, params={"username": username}) assert listed.status_code == 200 body = listed.json() record = next(item for item in body["records"] if item["username"] == username) assert record["mobileMask"] == "138****8000" assert set(record) >= { "id", "username", "displayName", "mobileMask", "status", "roleIds", "createdAt", "updatedAt", } _no_password(body) assert mobile not in listed.text assert password not in listed.text assert "cipher-text" not in listed.text assert not _committed(username) def test_login_hides_reason(client: TestClient, monkeypatch: pytest.MonkeyPatch): failures: list[int] = [] clears: list[int] = [] def consume(captcha_id: str, code: str) -> bool: return code == "ok" def record(db, user) -> None: failures.append(user.id) def clear(db, user) -> None: clears.append(user.id) user.failed_login_count = 0 user.locked_until = None locked = {"value": False} monkeypatch.setattr("app.services.rbac.consume_captcha", consume) monkeypatch.setattr("app.services.rbac.record_failure", record) monkeypatch.setattr("app.services.rbac.clear_failures", clear) monkeypatch.setattr("app.services.rbac.is_currently_locked", lambda user: locked["value"]) headers = _staff_headers() username = _suffix("login") password = "Abcdef12" created = client.post( "/api/admin/users", headers=headers, json={"username": username, "password": password, "displayName": "登录", "roleIds": []}, ) assert created.status_code == 200 missing_user = client.post( "/api/admin/auth/login", json={"username": _suffix("none"), "password": password, "captchaId": "c1", "captchaCode": "ok"}, ) assert missing_user.status_code == 401 assert missing_user.json()["detail"] == LOGIN_FAILURE assert failures == [] bad_captcha = client.post( "/api/admin/auth/login", json={"username": username, "password": password, "captchaId": "c1", "captchaCode": "bad"}, ) assert bad_captcha.status_code == 401 assert bad_captcha.json()["detail"] == LOGIN_FAILURE assert len(failures) == 1 bad_password = client.post( "/api/admin/auth/login", json={"username": username, "password": "Wrongpass1", "captchaId": "c1", "captchaCode": "ok"}, ) assert bad_password.status_code == 401 assert bad_password.json()["detail"] == LOGIN_FAILURE assert len(failures) == 2 locked["value"] = True locked_response = client.post( "/api/admin/auth/login", json={"username": username, "password": password, "captchaId": "c1", "captchaCode": "ok"}, ) assert locked_response.status_code == 401 assert locked_response.json()["detail"] == LOGIN_FAILURE assert len(failures) == 2 locked["value"] = False success = client.post( "/api/admin/auth/login", json={"username": username, "password": password, "captchaId": "c1", "captchaCode": "ok"}, ) assert success.status_code == 200 payload = success.json() assert payload["user"]["username"] == username assert payload["user"]["displayName"] == "登录" assert isinstance(payload["menus"], list) assert isinstance(payload["permissions"], list) assert payload["token"] _no_password(payload) assert clears == [created.json()["id"]] def test_login_menu_excludes_buttons(client: TestClient, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("app.services.rbac.consume_captcha", lambda captcha_id, code: True) monkeypatch.setattr("app.services.rbac.is_currently_locked", lambda user: False) monkeypatch.setattr("app.services.rbac.record_failure", lambda db, user: None) monkeypatch.setattr("app.services.rbac.clear_failures", lambda db, user: None) headers = _staff_headers() mark = uuid.uuid4().hex[:8] directory = client.post( "/api/admin/resources", headers=headers, json={ "type": "directory", "name": f"Dir{mark}", "title": "目录", "path": f"/t-{mark}", "sort": 2, "visible": True, "status": "active", }, ) assert directory.status_code == 200 directory_id = directory.json()["id"] later = client.post( "/api/admin/resources", headers=headers, json={ "parentId": directory_id, "type": "menu", "name": f"Later{mark}", "title": "后", "path": f"/t-{mark}/later", "component": f"/t-{mark}/later", "sort": 2, "status": "active", }, ) earlier = client.post( "/api/admin/resources", headers=headers, json={ "parentId": directory_id, "type": "menu", "name": f"Early{mark}", "title": "先", "path": f"/t-{mark}/early", "component": f"/t-{mark}/early", "sort": 1, "status": "active", }, ) hidden = client.post( "/api/admin/resources", headers=headers, json={ "parentId": directory_id, "type": "menu", "name": f"Off{mark}", "title": "停用页", "path": f"/t-{mark}/off", "component": f"/t-{mark}/off", "sort": 3, "status": "disabled", }, ) button_z = client.post( "/api/admin/resources", headers=headers, json={ "parentId": earlier.json()["id"], "type": "button", "name": f"b:z:{mark}", "title": "乙", "permission": f"b:z:{mark}", "visible": False, "status": "active", }, ) button_a = client.post( "/api/admin/resources", headers=headers, json={ "parentId": earlier.json()["id"], "type": "button", "name": f"b:a:{mark}", "title": "甲", "permission": f"b:a:{mark}", "visible": False, "status": "active", }, ) assert later.status_code == earlier.status_code == hidden.status_code == 200 assert button_z.status_code == button_a.status_code == 200 role = client.post( "/api/admin/roles", headers=headers, json={"code": f"R_{mark}", "name": "菜单角色", "description": ""}, ) assert role.status_code == 200 role_id = role.json()["id"] disabled_role = client.post( "/api/admin/roles", headers=headers, json={"code": f"R_OFF_{mark}", "name": "停用角色"}, ) assert disabled_role.status_code == 200 resource_ids = [ directory_id, later.json()["id"], earlier.json()["id"], button_a.json()["id"], button_z.json()["id"], ] replaced = client.put( f"/api/admin/roles/{role_id}/resources", headers=headers, json={"resourceIds": resource_ids}, ) assert replaced.status_code == 200 client.put( f"/api/admin/roles/{disabled_role.json()['id']}", headers=headers, json={"name": "停用角色", "description": "", "status": "disabled"}, ) extra_button = client.post( "/api/admin/resources", headers=headers, json={ "parentId": earlier.json()["id"], "type": "button", "name": f"b:off:{mark}", "title": "停用角色按钮", "permission": f"b:off:{mark}", "visible": False, "status": "active", }, ) client.put( f"/api/admin/roles/{disabled_role.json()['id']}/resources", headers=headers, json={"resourceIds": [extra_button.json()["id"]]}, ) username = _suffix("menu") created = client.post( "/api/admin/users", headers=headers, json={ "username": username, "password": "Abcdef12", "displayName": "菜单", "roleIds": [role_id, disabled_role.json()["id"]], }, ) assert created.status_code == 200 logged = client.post( "/api/admin/auth/login", json={"username": username, "password": "Abcdef12", "captchaId": "c", "captchaCode": "1"}, ) assert logged.status_code == 200 body = logged.json() nodes = list(_walk(body["menus"])) assert [node["type"] for node in nodes if node["type"] == "button"] == [] titles = [node["title"] for node in nodes] assert "停用页" not in titles children = body["menus"][0]["children"] assert [item["sort"] for item in children] == [1, 2] assert body["permissions"] == [f"b:a:{mark}", f"b:z:{mark}"] assert f"b:off:{mark}" not in body["permissions"] def test_delete_resource_with_children(client: TestClient): headers = _staff_headers() mark = uuid.uuid4().hex[:8] parent = client.post( "/api/admin/resources", headers=headers, json={"type": "directory", "name": f"P{mark}", "title": "父", "sort": 1, "status": "active"}, ) assert parent.status_code == 200 child = client.post( "/api/admin/resources", headers=headers, json={ "parentId": parent.json()["id"], "type": "menu", "name": f"C{mark}", "title": "子", "path": f"/c-{mark}", "component": f"/c-{mark}", "status": "active", }, ) assert child.status_code == 200 denied = client.delete(f"/api/admin/resources/{parent.json()['id']}", headers=headers) assert denied.status_code == 400 assert denied.json()["detail"] == "存在子节点,不能删除" removed = client.delete(f"/api/admin/resources/{child.json()['id']}", headers=headers) assert removed.status_code == 200 removed_parent = client.delete(f"/api/admin/resources/{parent.json()['id']}", headers=headers) assert removed_parent.status_code == 200 def test_preserve_last_full_admin(client: TestClient): connection = client.app.state.connection db = Session( bind=connection, autoflush=False, expire_on_commit=False, join_transaction_mode="create_savepoint", ) nested = db.begin_nested() try: db.execute(update(AdminUser).values(status="disabled")) db.flush() db.expire_all() assert count_full_admins(db) == 0 def make_user(tag: str) -> AdminUser: now = utcnow() role = AdminRole(code=_suffix("RF"), name=tag, status="active", created_at=now, updated_at=now) user = AdminUser( username=_suffix(tag), password_hash="x", display_name=tag, mobile_mask="", status="active", failed_login_count=0, created_at=now, updated_at=now, ) db.add_all([role, user]) db.flush() for resource_id in db.scalars(select(AdminResource.id).where(AdminResource.status == "active")).all(): db.add(AdminRoleResource(role_id=role.id, resource_id=resource_id)) db.add(AdminUserRole(user_id=user.id, role_id=role.id)) db.flush() return user first = make_user("fulla") second = make_user("fullb") db.expire_all() assert count_full_admins(db) == 2 def disable_first() -> None: row = db.get(AdminUser, first.id) assert row is not None row.status = "disabled" preserve_full_admin(db, disable_first) db.expire_all() assert count_full_admins(db) == 1 def disable_last() -> None: row = db.get(AdminUser, second.id) assert row is not None row.status = "disabled" with pytest.raises(HTTPException) as caught: preserve_full_admin(db, disable_last) assert caught.value.detail == KEEP_FULL_ADMIN finally: nested.rollback() db.close() def test_seed_superuser_is_idempotent(client: TestClient): connection = client.app.state.connection db = Session( bind=connection, autoflush=False, expire_on_commit=False, join_transaction_mode="create_savepoint", ) username = _suffix("boot") try: seed_superuser(db, username, "Seedpass1") seed_superuser(db, username, "Seedpass1") db.commit() users = db.scalars(select(AdminUser).where(AdminUser.username == username)).all() assert len(users) == 1 role = db.scalar(select(AdminRole).where(AdminRole.code == "R_SUPER")) assert role is not None assert role.name == "超级管理员" directory = db.scalar( select(AdminResource).where(AdminResource.parent_id.is_(None), AdminResource.name == "System") ) assert directory is not None assert directory.path == "/system" assert directory.icon == "ri:settings-3-line" page = db.scalar(select(AdminResource).where(AdminResource.parent_id == directory.id, AdminResource.name == "User")) assert page is not None assert page.component == "/system/user" button = db.scalar( select(AdminResource).where(AdminResource.parent_id == page.id, AdminResource.name == "user:add") ) assert button is not None assert button.type == "button" assert button.permission == "user:add" assert button.visible is False linked = db.get(AdminUserRole, (users[0].id, role.id)) assert linked is not None finally: db.close() assert not _committed(username)