Files
zkxw/backend/tests/test_market_project_assignment.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

202 lines
11 KiB
Python

"""Mobile attribution writes and scoped reads against isolated SQLite records."""
import pytest
from fastapi import HTTPException
from app.core.config import settings
from app.models.project_management import Project, ProjectTeam
from app.models.models import BizPresignOrder, BizCollectionProtocol
from app.models.research_donation import BizResearchDonation
from app.schemas.market_mobile import MarketPresignOrderBody, MarketProtocolBody
from app.services import market_presign, market_protocol
from test_market_protocol_api import _session, _masters, _ctx, _user, _body, _cell, _donation
@pytest.fixture
def db():
session = _session()
_masters(session)
session.add_all([
ProjectTeam(id='team', tenant_no='tenant-a', team_name='团队', leader_user_id='u1'),
Project(id='project', tenant_no='tenant-a', project_no='P', project_name='项目', team_id='team', leader_user_id='u1', status='active', biz_types='presign,cell_storage,research_donation'),
])
session.commit()
yield session
session.close()
@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation'])
def test_mobile_create_persists_and_returns_project(db, kind):
if kind == 'presign':
result = market_presign.create_presign_order(db, body=MarketPresignOrderBody(motherName='母亲', projectId='project'), ctx=_ctx('specialist'), user_info=_user())
basic = result
else:
result = market_protocol.create_protocol(db, body=_body(kind, projectId='project', samples=[], attachments={}), ctx=_ctx('specialist'), user_info=_user())
basic = result['basic']
model = {'presign': BizPresignOrder, 'cell_storage': BizCollectionProtocol, 'research_donation': BizResearchDonation}[kind]
db.expire_all()
assert db.get(model, basic['id']).project_id == 'project'
assert basic['projectId'] == 'project'
assert basic['projectName'] == '项目'
assert basic['teamId'] == 'team'
assert basic['teamName'] == '团队'
@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation'])
def test_mobile_creation_obeys_required_configuration(db, monkeypatch, kind):
monkeypatch.setattr(settings, 'PROJECT_ASSIGNMENT_REQUIRED', True)
with pytest.raises(HTTPException):
if kind == 'presign':
market_presign.create_presign_order(db, body=MarketPresignOrderBody(motherName='母亲'), ctx=_ctx('specialist'), user_info=_user())
else:
market_protocol.create_protocol(db, body=_body(kind, samples=[], attachments={}), ctx=_ctx('specialist'), user_info=_user())
@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation'])
def test_scoped_lists_and_immutable_edits(db, kind):
if kind == 'presign':
own = BizPresignOrder(id='own', tenant_no='tenant-a', sales_user_id='u1', order_status='active', project_id='project')
hidden = BizPresignOrder(id='hidden', tenant_no='tenant-a', sales_user_id='u2', order_status='active', project_id='project')
db.add_all([own, hidden])
else:
factory = _cell if kind == 'cell_storage' else _donation
own = factory(db, 'own')
hidden = factory(db, 'hidden', sales='u2')
own.project_id = hidden.project_id = 'project'
db.commit()
service = market_presign if kind == 'presign' else market_protocol
listing = service.list_presign_orders if kind == 'presign' else service.list_protocols
params = {} if kind == 'presign' else {'source': kind}
rows, total = listing(db, ctx=_ctx('specialist'), user_info=_user(), project_id='project', team_id='team', **params)
assert total == 1
assert rows[0]['id'] == 'own'
assert rows[0]['projectName'] == '项目'
assert listing(db, ctx=_ctx('specialist'), user_info=_user(), project_id='missing', **params)[1] == 0
db.get(Project, 'project').status = 'disabled'
db.commit()
kwargs = {'order_id': 'own'} if kind == 'presign' else {'protocol_id': 'own', 'source': kind}
update = service.update_presign_order if kind == 'presign' else service.update_protocol
body = MarketPresignOrderBody(remark='保留历史') if kind == 'presign' else MarketProtocolBody(source=kind, remark='保留历史')
update(db, ctx=_ctx('specialist'), user_info=_user(), body=body, **kwargs)
db.refresh(own)
assert own.project_id == 'project'
body = MarketPresignOrderBody(projectId=None) if kind == 'presign' else MarketProtocolBody(source=kind, projectId=None)
with pytest.raises(HTTPException, match='项目归属维护'):
update(db, ctx=_ctx('specialist'), user_info=_user(), body=body, **kwargs)
@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation'])
def test_mobile_rejects_foreign_project_and_legacy_backfill(db, kind):
db.get(Project, 'project').tenant_no = 'tenant-other'
db.commit()
with pytest.raises(HTTPException):
if kind == 'presign':
market_presign.create_presign_order(db, body=MarketPresignOrderBody(motherName='母亲', projectId='project'), ctx=_ctx('specialist'), user_info=_user())
else:
market_protocol.create_protocol(db, body=_body(kind, projectId='project', samples=[], attachments={}), ctx=_ctx('specialist'), user_info=_user())
if kind == 'presign':
row = BizPresignOrder(id='legacy', tenant_no='tenant-a', sales_user_id='u1', order_status='active')
db.add(row)
else:
row = (_cell if kind == 'cell_storage' else _donation)(db, 'legacy')
db.commit()
with pytest.raises(HTTPException, match='项目归属维护'):
if kind == 'presign':
market_presign.update_presign_order(db, order_id='legacy', body=MarketPresignOrderBody(projectId='project'), ctx=_ctx('specialist'), user_info=_user())
else:
market_protocol.update_protocol(db, source=kind, protocol_id='legacy', body=MarketProtocolBody(source=kind, projectId='project'), ctx=_ctx('specialist'), user_info=_user())
db.refresh(row)
assert row.project_id is None
@pytest.mark.parametrize('role', ['specialist', 'manager'])
@pytest.mark.parametrize('resource', ['protocols', 'presign-orders'])
def test_mobile_http_list_binds_project_and_team_aliases(db, role, resource):
import asyncio
import httpx
from fastapi import FastAPI
from app.api.market_mobile import router
from app.core.database import get_db
row = _cell(db, 'visible')
row.project_id = 'project'
hidden = _cell(db, 'hidden', sales='u2', manager='manager-other')
hidden.project_id = 'project'
db.add_all([
BizPresignOrder(id='presign-own', tenant_no='tenant-a', sales_user_id='u1', region_manager_user_id='manager-a', order_status='active', project_id='project'),
BizPresignOrder(id='presign-hidden', tenant_no='tenant-a', sales_user_id='u2', region_manager_user_id='manager-other', order_status='active', project_id='project'),
])
db.commit()
app = FastAPI()
app.include_router(router)
async def database():
yield db
async def user():
return _user('manager-a' if role == 'manager' else 'u1')
app.dependency_overrides[get_db] = database
for route in router.routes:
for dependency in route.dependant.dependencies:
if dependency.name == 'user':
app.dependency_overrides[dependency.call] = user
async def check():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
url = f'/mini/market/{role}/{resource}'
response = await client.get(url, params={'source': 'cell_storage', 'projectId': 'project', 'teamId': 'team'})
assert response.status_code == 200
data = response.json()['data']
assert data['total'] == 1
assert data['list'][0]['projectName'] == '项目'
response = await client.get(url, params={'source': 'cell_storage', 'projectId': 'missing'})
assert response.json()['data']['total'] == 0
response = await client.get(url, params={'source': 'cell_storage', 'teamId': 'missing'})
assert response.json()['data']['total'] == 0
asyncio.run(check())
@pytest.mark.parametrize('role', ['specialist', 'manager'])
def test_mobile_history_filter_options_keep_field_scope(db, role):
from app.services.project_management import filter_options
from app.services.market_field_scope import apply_presign_scope
db.get(Project, 'project').status = 'disabled'
db.add(Project(id='hidden-project', tenant_no='tenant-a', project_no='H', project_name='隐藏', team_id='team', leader_user_id='u2', status='active', biz_types='cell_storage'))
db.add_all([
BizPresignOrder(id='own', tenant_no='tenant-a', sales_user_id='u1', region_manager_user_id='manager-a', project_id='project'),
BizPresignOrder(id='hidden', tenant_no='tenant-a', sales_user_id='u2', region_manager_user_id='manager-other', project_id='hidden-project'),
])
db.commit()
user = _user('manager-a' if role == 'manager' else 'u1')
query = apply_presign_scope(db.query(BizPresignOrder), db, _ctx(role, user['user_id']), user_info=user)
result = filter_options(db, 'tenant-a', user, 'presign', business_scope=query)
assert [row['id'] for row in result['list']] == ['project']
@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation'])
def test_mobile_cross_team_assignment_requires_explicit_all_permission(db, kind):
from app.models.models import SysMenu, SysRole, SysRoleMenu, SysUserRole
# Becoming a project leader does not make the user a member of its team.
db.get(ProjectTeam, 'team').leader_user_id = 'u2'
db.add_all([
SysRole(id='assignment-role', tenant_no='tenant-a', role_name='归属维护', data_scope='4'),
SysUserRole(id='assignment-user-role', tenant_no='tenant-a', user_id='u1', role_id='assignment-role'),
SysMenu(id='assignment-menu', tenant_no='tenant-a', menu_name='归属维护', perms='project:assignment:edit'),
SysRoleMenu(id='assignment-role-menu', tenant_no='tenant-a', role_id='assignment-role', menu_id='assignment-menu'),
])
db.commit()
def create():
if kind == 'presign':
return market_presign.create_presign_order(db, body=MarketPresignOrderBody(motherName='母亲', projectId='project'), ctx=_ctx('specialist'), user_info={**_user(), 'role': 'admin'})
return market_protocol.create_protocol(db, body=_body(kind, projectId='project', samples=[], attachments={}), ctx=_ctx('specialist'), user_info={**_user(), 'role': 'admin'})
with pytest.raises(HTTPException) as caught:
create()
assert caught.value.status_code == 403
db.add_all([
SysMenu(id='all-menu', tenant_no='tenant-a', menu_name='全部项目', menu_type='F', visible='0', perms='project:project:all'),
SysRoleMenu(id='all-role-menu', tenant_no='tenant-a', role_id='assignment-role', menu_id='all-menu'),
])
db.commit()
result = create()
assert (result if kind == 'presign' else result['basic'])['projectId'] == 'project'