Files
gsfdc/server/tests/test_rbac.py
T

722 lines
23 KiB
Python

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)