Files
zkxw/backend/tests/test_project_permission_administration.py
T
zlwu 0bddf38e52 feat(project): enforce team-scoped selection with explicit all-project permission
Separate cross-team selection from attribution maintenance and apply the same scope to options, history filters and writes. Add an opt-in tenant permission without granting existing roles.

Protect authorization mutation paths against self-grants, validate tenant targets and links, and include menu ancestors atomically. Cover role union, revocation, mobile writes and administration boundaries with regression tests.
2026-11-02 17:09:20 +08:00

181 lines
8.9 KiB
Python

"""Users cannot self-grant project access through the permission administration APIs."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.api.admin import router
from app.core.auth import get_current_user
from app.core.database import get_db
from app.models.base import Base
from app.models.models import SysMenu, SysPost, SysRole, SysRoleMenu, SysUser, SysUserRole
@pytest.fixture
def env():
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
db = Session(engine)
user = dict(user_id='u', role='admin', tenant_no='t')
db.add_all([
SysUser(id='u', tenant_no='t', user_no='member', nick_name='Member'),
SysUser(id='foreign-user', tenant_no='foreign', user_no='foreign', nick_name='Foreign'),
SysRole(id='role', tenant_no='t', role_name='Member', data_scope='4'),
SysRole(id='foreign-role', tenant_no='foreign', role_name='Foreign'),
SysUserRole(id='ur', tenant_no='t', user_id='u', role_id='role'),
SysPost(id='post', tenant_no='t', post_name='Post', post_code='POST'),
SysPost(id='foreign-post', tenant_no='foreign', post_name='Foreign', post_code='FOREIGN'),
SysMenu(id='root', tenant_no='t', parent_id='0', menu_name='Web'),
SysMenu(id='project', tenant_no='t', parent_id='root', menu_name='项目', perms='menu:project'),
SysMenu(id='all', tenant_no='t', parent_id='project', menu_name='全部项目', menu_type='F', perms='project:project:all'),
SysMenu(id='foreign-menu', tenant_no='foreign', parent_id='0', menu_name='Foreign'),
])
db.commit()
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_current_user] = lambda: user
app.dependency_overrides[get_db] = lambda: db
with TestClient(app) as client:
yield db, user, client
db.close()
engine.dispose()
def grant(db, code):
db.add_all([SysMenu(id=code, tenant_no='t', menu_name=code, perms=code),
SysRoleMenu(id=code, tenant_no='t', role_id='role', menu_id=code)])
db.commit()
@pytest.mark.parametrize('method,path,body', [
('PUT', '/role-management/role/menus', {'menuIds': ['all']}),
('POST', '/role-management', {'roleName': '超级管理员'}),
('PUT', '/role-management/role', {'roleName': '超级管理员'}),
('DELETE', '/role-management/role', None),
('PUT', '/role-management/role/data-scope', {'dataScope': '1'}),
('POST', '/users', {'userNo': 'attacker', 'nickName': 'Attacker', 'password': 'TestOnly123!', 'roleIds': ['role']}),
('PUT', '/users/u', {'roleIds': ['foreign-role']}),
('PUT', '/users/u', {'postIds': ['post']}),
('POST', '/users/u/reset-password', None),
('DELETE', '/users/u', None),
('PUT', '/posts/post/roles', {'roleIds': ['role']}),
('PUT', '/posts/post/users', {'userIds': ['u']}),
('PUT', '/posts/post', {'status': '1'}),
('POST', '/menus', {'menuName': 'Grant', 'perms': 'project:project:all'}),
('PUT', '/menus/project', {'perms': 'project:project:all'}),
('DELETE', '/menus/all', None),
])
def test_business_admin_cannot_mutate_permission_graph(env, method, path, body):
_, _, client = env
result = client.request(method, '/web/admin' + path, json=body)
assert result.status_code == 403
def test_role_manager_grants_leaf_and_ancestors_only(env):
db, _, client = env
grant(db, 'system:role:list')
result = client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all', 'system:role:list']})
assert result.json()['code'] == 200
rows = db.query(SysRoleMenu).filter_by(role_id='role', tenant_no='t', del_flag=0).all()
assert {r.menu_id for r in rows} == {'all', 'project', 'root', 'system:role:list'}
# Same save is idempotent and does not create duplicate active grants.
assert client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all', 'system:role:list']}).json()['code'] == 200
assert db.query(SysRoleMenu).filter_by(role_id='role', tenant_no='t', del_flag=0).count() == 4
@pytest.mark.parametrize('super_admin', [False, True])
@pytest.mark.parametrize('path,body', [
('/role-management/foreign-role/menus', {'menuIds': ['all']}),
('/role-management/role/menus', {'menuIds': ['all', 'foreign-menu']}),
('/role-management/foreign-role', {'roleName': 'Changed'}),
('/role-management/foreign-role/data-scope', {'dataScope': '1'}),
('/menus/foreign-menu', {'perms': 'project:project:all'}),
('/menus/project', {'parentId': 'foreign-menu'}),
('/posts/foreign-post', {'status': '1'}),
('/users/foreign-user', {'roleIds': ['role']}),
('/users/u', {'roleIds': ['foreign-role']}),
('/users/u', {'postIds': ['foreign-post']}),
])
def test_permission_admin_cannot_write_foreign_targets_or_links(env, path, body, super_admin):
db, user, client = env
for code in ('system:role:list', 'system:menu:list', 'system:post:list', 'system:user:list'):
grant(db, code)
if super_admin:
user['role'] = 'super_admin'
before = {r.menu_id for r in db.query(SysRoleMenu).filter_by(role_id='role', del_flag=0)}
result = client.put('/web/admin' + path, json=body)
assert result.json().get('code', result.status_code) in (400, 403, 404)
db.expire_all()
assert {r.menu_id for r in db.query(SysRoleMenu).filter_by(role_id='role', del_flag=0)} == before
assert db.get(SysRole, 'foreign-role').role_name == 'Foreign'
assert db.get(SysUser, 'foreign-user').tenant_no == 'foreign'
@pytest.mark.parametrize('parent', ['missing', 'all'])
def test_role_grant_rejects_broken_or_cyclic_ancestry_atomically(env, parent):
db, user, client = env
user['role'] = 'super_admin'
db.get(SysMenu, 'project').parent_id = parent
db.commit()
result = client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all']})
assert result.json().get('code', result.status_code) == 400
assert db.query(SysRoleMenu).count() == 0
@pytest.mark.parametrize('code,path,body', [
('system:role:list', '/role-management/role', {'remark': 'authorized'}),
('system:role:list', '/role-management/role/data-scope', {'dataScope': '4'}),
('system:user:list', '/users/u', {'roleIds': ['role'], 'postIds': ['post']}),
('system:post:list', '/posts/post/roles', {'roleIds': ['role']}),
('system:post:list', '/posts/post/users', {'userIds': ['u']}),
('system:menu:list', '/menus/all', {'menuName': '全部项目'}),
])
def test_authorized_tenant_administrators_can_manage_their_module(env, code, path, body):
db, _, client = env
grant(db, code)
assert client.put('/web/admin' + path, json=body).json()['code'] == 200
def test_web_and_mobile_ancestor_roots_remain_separate(env):
db, user, client = env
user['role'] = 'super_admin'
db.add_all([
SysMenu(id='mobile-root', tenant_no='t', parent_id='0', menu_name='Mobile'),
SysMenu(id='mobile-leaf', tenant_no='t', parent_id='mobile-root', menu_name='Mobile business'),
])
db.commit()
assert client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all', 'mobile-leaf']}).json()['code'] == 200
assert {r.menu_id for r in db.query(SysRoleMenu).filter_by(role_id='role', del_flag=0)} == {'all', 'project', 'root', 'mobile-leaf', 'mobile-root'}
@pytest.mark.parametrize('method,path,body', [
('POST', '/qc-material-amount-auth/users', {'userId': 'u'}),
('DELETE', '/qc-material-amount-auth/users/u', None),
('POST', '/qc-material-amount-auth/users/batch-revoke', {'userIds': ['u']}),
])
def test_business_admin_cannot_self_bind_specialized_authorization_roles(env, method, path, body):
from app.api.qc import router as qc_router
_, _, client = env
client.app.include_router(qc_router)
assert client.request(method, '/web/admin' + path, json=body).status_code == 403
def test_specialized_authorization_manager_can_grant_and_revoke(env):
from app.api.qc import router as qc_router
from app.core.qc_material_amount_auth_constants import (
QC_MATERIAL_AMOUNT_AUTH_LIST_PERM, QC_MATERIAL_AMOUNT_VIEW_ROLE_ID,
QC_MATERIAL_AMOUNT_VIEW_ROLE_NAME, QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME,
)
from app.models.models import SysDept
db, _, client = env
client.app.include_router(qc_router)
grant(db, QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)
db.add_all([
SysDept(id='qc-dept', tenant_no='t', dept_name=QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME),
SysRole(id=QC_MATERIAL_AMOUNT_VIEW_ROLE_ID, tenant_no='t', role_name=QC_MATERIAL_AMOUNT_VIEW_ROLE_NAME),
])
db.get(SysUser, 'u').dept_id = 'qc-dept'
db.commit()
assert client.post('/web/admin/qc-material-amount-auth/users', json={'userId': 'u'}).json()['code'] == 200
assert client.delete('/web/admin/qc-material-amount-auth/users/u').json()['code'] == 200