Files
zkxw/backend/tests/test_project_management.py
T
zlwu a4c4028144 fix(projects): resolve runtime permissions and migration compatibility
Use existing menu permissions for management actions, preserve shared list styles in teleported detail dialogs, and align project attribution column collations with the project primary key. Record isolated MySQL and browser validation while retaining the real-device acceptance gate.
2026-11-02 16:04:31 +08:00

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:assignment:edit'),
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()