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.
193 lines
12 KiB
Python
193 lines
12 KiB
Python
"""Project attribution isolation and history contract, using an in-memory database."""
|
|
import importlib.util
|
|
import unittest
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session
|
|
from fastapi import HTTPException
|
|
|
|
|
|
class ProjectManagementTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.assertIsNotNone(importlib.util.find_spec('app.services.project_management'), 'project service missing')
|
|
from app.services import project_management as svc
|
|
from app.models.project_management import Project, ProjectTeam, ProjectTeamMember
|
|
from app.models.base import Base
|
|
self.svc, self.Project, self.Team = svc, Project, ProjectTeam
|
|
self.engine = create_engine('sqlite:///:memory:')
|
|
Base.metadata.create_all(self.engine)
|
|
self.db = Session(self.engine)
|
|
self.user = {'user_id': 'u', 'tenant_no': 't', 'role': 'super_admin'}
|
|
self.db.add_all([ProjectTeam(id='g', tenant_no='t', team_name='Team', leader_user_id='u'),
|
|
Project(id='p', tenant_no='t', project_no='P1', project_name='Project', team_id='g', leader_user_id='u', biz_types='cell_storage,presign', status='active')])
|
|
self.db.commit()
|
|
|
|
def tearDown(self):
|
|
if hasattr(self, 'db'):
|
|
self.db.close()
|
|
self.engine.dispose()
|
|
|
|
def test_assignment_and_tenant_boundary(self):
|
|
self.assertEqual(self.svc.validate_assignment(self.db, 't', self.user, 'p', 'cell_storage'), 'p')
|
|
with self.assertRaises(HTTPException):
|
|
self.svc.validate_assignment(self.db, 'other', self.user, 'p', 'cell_storage')
|
|
with self.assertRaises(HTTPException):
|
|
self.svc.validate_assignment(self.db, 't', self.user, 'p', 'basic_experiment')
|
|
|
|
def test_legacy_edit_keeps_disabled_project(self):
|
|
self.db.get(self.Project, 'p').status = 'disabled'
|
|
self.db.commit()
|
|
self.assertEqual(self.svc.validate_assignment(self.db, 't', self.user, 'p', 'cell_storage', current_project_id='p'), 'p')
|
|
with self.assertRaises(HTTPException):
|
|
self.svc.validate_assignment(self.db, 't', self.user, None, 'cell_storage', current_project_id='p')
|
|
with self.assertRaises(HTTPException):
|
|
self.svc.validate_assignment(self.db, 't', self.user, None, 'cell_storage', required=True)
|
|
|
|
def test_statistics_excludes_foreign_and_deleted(self):
|
|
from app.models.models import BizCollectionProtocol
|
|
self.db.add_all([BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', project_id='p'),
|
|
BizCollectionProtocol(id='b', protocol_no='B', tenant_no='other', project_id='p'),
|
|
BizCollectionProtocol(id='c', protocol_no='C', tenant_no='t', project_id='p', del_flag=1),
|
|
BizCollectionProtocol(id='d', protocol_no='D', tenant_no='t')])
|
|
self.db.commit()
|
|
result = self.svc.statistics(self.db, 't', self.user)
|
|
self.assertEqual(result['totals']['cell_storage'], 2)
|
|
self.assertEqual(self.svc.records(self.db, 't', self.user, 'cell_storage')['total'], 2)
|
|
self.assertEqual(result['unassignedTotals']['cell_storage'], 1)
|
|
self.assertEqual(self.svc.records(self.db, 't', self.user, 'cell_storage', projectId='p')['total'], 1)
|
|
|
|
def test_assignment_preview_and_atomic_snapshot(self):
|
|
from app.models.models import BizCollectionProtocol, BizPresignOrder
|
|
self.db.add_all([BizPresignOrder(id='s', tenant_no='t', project_id=None, converted_protocol_id='a'),
|
|
BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', source_presign_order_id='s')])
|
|
self.db.commit()
|
|
result = self.svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'a', 'p')
|
|
self.assertEqual(len(result['records']), 2)
|
|
with self.assertRaises(HTTPException):
|
|
self.svc.assign(self.db, 't', self.user, 'cell_storage', 'a', 'p', 'correction', [])
|
|
self.db.rollback()
|
|
self.assertIsNone(self.db.get(BizCollectionProtocol, 'a').project_id)
|
|
self.svc.assign(self.db, 't', self.user, 'cell_storage', 'a', 'p', 'correction', result['records'])
|
|
self.assertEqual(self.db.get(BizPresignOrder, 's').project_id, 'p')
|
|
self.assertEqual(len(self.svc.assignment_history(self.db, 't', self.user, 'cell_storage', 'a')['list']), 1)
|
|
|
|
def test_team_child_ids_survive_edits(self):
|
|
from app.models.models import SysUser
|
|
self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User'))
|
|
self.db.commit()
|
|
body = dict(teamName='Team', leaderUserId='u', members=[{'userId': 'u'}])
|
|
first = self.svc.save_entity(self.db, 't', self.user, self.Team, body, 'g')
|
|
second = self.svc.save_entity(self.db, 't', self.user, self.Team, body, 'g')
|
|
self.assertEqual(first['members'][0]['id'], second['members'][0]['id'])
|
|
with self.assertRaises(HTTPException):
|
|
self.svc.delete_entity(self.db, 't', self.user, self.Team, 'g')
|
|
|
|
def test_non_member_admin_cannot_choose_project(self):
|
|
# An ordinary admin with no enabled-role permissions is not a substitute recorder.
|
|
with self.assertRaises(HTTPException) as error:
|
|
self.svc.validate_assignment(self.db, 't', {'user_id': 'stranger', 'role': 'admin'}, 'p', 'cell_storage')
|
|
self.assertEqual(error.exception.status_code, 403)
|
|
|
|
def test_non_member_data_scope_does_not_expand(self):
|
|
from app.models.models import BizCollectionProtocol
|
|
self.db.add(BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', project_id='p'))
|
|
self.db.commit()
|
|
user = {'user_id': 'u', 'role': 'admin'}
|
|
self.assertEqual(self.svc.statistics(self.db, 't', user)['totals']['cell_storage'], 0)
|
|
self.assertEqual(self.svc.records(self.db, 't', user, 'cell_storage')['total'], 0)
|
|
|
|
def test_empty_date_input_is_optional(self):
|
|
from app.models.models import SysUser
|
|
self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User'))
|
|
self.db.commit()
|
|
result = self.svc.save_entity(self.db, 't', self.user, self.Project,
|
|
dict(projectName='New', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate=''))
|
|
self.assertTrue(result['projectNo'].startswith('XM'))
|
|
self.assertIsNone(result['startDate'])
|
|
|
|
def test_conversion_table_reverse_graph_and_audit(self):
|
|
from app.models.models import BizCollectionProtocol
|
|
from app.models.research_donation import BizResearchDonation, BizProtocolDonationConvert
|
|
self.db.get(self.Project, 'p').biz_types = 'cell_storage,research_donation'
|
|
self.db.add_all([BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t'),
|
|
BizResearchDonation(id='d', donation_no='D', tenant_no='t'),
|
|
BizProtocolDonationConvert(id='c', protocol_id='a', research_donation_id='d', biz_no='A', convert_type='research_to_storage', tenant_no='t')])
|
|
self.db.commit()
|
|
preview = self.svc.preview_assignment(self.db, 't', self.user, 'research_donation', 'd', 'p')
|
|
self.assertEqual({r['bizType'] for r in preview['records']}, {'cell_storage', 'research_donation'})
|
|
self.svc.assign(self.db, 't', self.user, 'research_donation', 'd', 'p', 'reason', preview['records'])
|
|
self.assertEqual(self.db.get(BizCollectionProtocol, 'a').project_id, 'p')
|
|
self.assertEqual(self.db.get(BizResearchDonation, 'd').project_id, 'p')
|
|
|
|
def test_preview_rejects_inaccessible_link_without_writes(self):
|
|
from app.models.models import BizCollectionProtocol, BizPresignOrder
|
|
self.db.add_all([BizPresignOrder(id='s', tenant_no='other', converted_protocol_id='a'),
|
|
BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', source_presign_order_id='s')])
|
|
self.db.commit()
|
|
with self.assertRaises(HTTPException):
|
|
self.svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'a', 'p')
|
|
self.assertIsNone(self.db.get(BizCollectionProtocol, 'a').project_id)
|
|
|
|
def test_date_end_inclusive_and_business_types_separate(self):
|
|
from datetime import datetime
|
|
from app.models.models import BizPilotApplication
|
|
self.db.add_all([BizPilotApplication(id='a', tenant_no='t', project_id='p', biz_type='basic_experiment', channel_service_order_id='', create_time=datetime(2026, 1, 2, 23, 59)),
|
|
BizPilotApplication(id='b', tenant_no='t', project_id='p', biz_type='pilot_service', channel_service_order_id='', create_time=datetime(2026, 1, 3)),
|
|
BizPilotApplication(id='c', tenant_no='t', project_id='p', biz_type='clinical_application', channel_service_order_id='', create_time=datetime(2026, 1, 2))])
|
|
self.db.commit()
|
|
result = self.svc.statistics(self.db, 't', self.user, startDate='2026-01-02', endDate='2026-01-02')
|
|
self.assertEqual(result['totals']['basic_experiment'], 1)
|
|
self.assertEqual(result['totals']['pilot_service'], 0)
|
|
self.assertEqual(sum(result['totals'].values()), 1)
|
|
|
|
def test_routes_exist_and_protect_management(self):
|
|
from app.api.project_management import router
|
|
paths = {r.path for r in router.routes}
|
|
self.assertIn('/project-management/options', paths)
|
|
self.assertIn('/web/admin/project-management/assignments/preview', paths)
|
|
with self.assertRaises(HTTPException) as error:
|
|
self.svc.check_permission(self.db, 't', {'user_id': 'stranger', 'role': 'admin'}, 'project:team:list')
|
|
self.assertEqual(error.exception.status_code, 403)
|
|
|
|
def test_enabled_role_union_authorizes_substitute_and_keeps_data_scope(self):
|
|
from app.models.models import SysRole, SysUserRole, SysRoleMenu, SysMenu, BizCollectionProtocol
|
|
self.db.add_all([SysRole(id='role1', role_name='reader', tenant_no='t', data_scope='4'),
|
|
SysRole(id='role2', role_name='assignment', tenant_no='t', data_scope='4'),
|
|
SysUserRole(id='ur1', user_id='outsider', role_id='role1', tenant_no='t'),
|
|
SysUserRole(id='ur2', user_id='outsider', role_id='role2', tenant_no='t'),
|
|
SysMenu(id='menu1', tenant_no='t', menu_name='全部项目', perms='project:project:all'),
|
|
SysRoleMenu(id='rm1', tenant_no='t', role_id='role2', menu_id='menu1'),
|
|
BizCollectionProtocol(id='mine', protocol_no='Mine', tenant_no='t', create_by='outsider', project_id='p'),
|
|
BizCollectionProtocol(id='foreign', protocol_no='Other', tenant_no='t', create_by='u', project_id='p')])
|
|
self.db.commit()
|
|
user = dict(user_id='outsider', role='admin')
|
|
self.assertEqual(self.svc.validate_assignment(self.db, 't', user, 'p', 'cell_storage'), 'p')
|
|
self.assertEqual(self.svc.records(self.db, 't', user, 'cell_storage')['total'], 1)
|
|
self.assertEqual(self.svc.statistics(self.db, 't', user)['totals']['cell_storage'], 1)
|
|
self.db.get(SysRole, 'role2').status = '0'
|
|
self.db.commit()
|
|
with self.assertRaises(HTTPException):
|
|
self.svc.validate_assignment(self.db, 't', user, 'p', 'cell_storage')
|
|
|
|
def test_mysql_migration_has_repeat_guards_and_menu_parent_structure(self):
|
|
from pathlib import Path
|
|
root = Path(__file__).resolve().parents[2]
|
|
migration = (root / 'docs/sql/migrations/2026-11-02-project_management.sql').read_text()
|
|
self.assertEqual(migration.count('CREATE TABLE IF NOT EXISTS'), 6)
|
|
self.assertEqual(migration.count('information_schema.columns'), 9)
|
|
self.assertEqual(migration.count('information_schema.statistics'), 4)
|
|
self.assertNotIn(' BEFORE ', migration)
|
|
self.assertNotIn('JSON_TABLE', migration)
|
|
self.assertIn('INTO @project_charset, @project_collation', migration)
|
|
self.assertEqual(migration.count('ADD COLUMN project_id VARCHAR(64) CHARACTER SET'), 4)
|
|
self.assertEqual(migration.count('MODIFY COLUMN project_id VARCHAR(64) CHARACTER SET'), 4)
|
|
menus = (root / 'docs/sql/migrations/2026-11-02-project_management_menu_seed.sql').read_text()
|
|
self.assertEqual(menus.count('AND NOT EXISTS'), 6)
|
|
self.assertEqual(menus.count("parent.perms='menu:market'"), 4)
|
|
self.assertIn("parent.perms='project:team:list'", menus)
|
|
self.assertIn("parent.perms='project:project:list'", menus)
|
|
self.assertNotIn('INSERT INTO sys_role_menu', menus)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|