feat: expose complete scoped presign actions to mobile clients.

This commit is contained in:
zlwu committed 2026-10-11 11:43:38 +08:00
1 parent 34bf9aa0dc
commit 7e930c77fd
4 files changed
+2731 -4

No files matched your search

File diff suppressed because it is too large. Load diff
+729
View File
@@ -0,0 +1,729 @@
"""移动市场表单选项、附件暂存与动作白名单。
该模块是移动市场写接口的安全边界:客户端不能选择任意字典、目录或附件 URL。
"""
from __future__ import annotations
import os
import re
import uuid
import zipfile
from io import BytesIO
from typing import Any, Dict, Iterable, List, Optional
from fastapi import HTTPException
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.storage import storage
from app.models.models import (
BizAttachment,
BizCellSampleType,
BizCollectionHospital,
BizProtocolCollectionInfo,
BizCollectionRegion,
BizCollectionRegionStaff,
BizItemUnit,
SysDictData,
SysUser,
)
from app.models.research_donation import BizResearchDonationCollection
from app.services.market_field_scope import (
MarketFieldContext,
assert_cell_protocol_access,
assert_donation_access,
assert_presign_access,
)
FORM_MODULES = frozenset({'presign', 'protocol', 'payment', 'freight'})
UPLOAD_MODULES = frozenset({'presign', 'protocol', 'payment', 'sample'})
DICT_BY_MODULE = {
'payment': ('zzfs', 'transferTypes'),
'freight': ('sample_transport_mode', 'transportModes'),
}
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
PRESIGN_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
MAX_ATTACHMENT_REFS = 20
MAX_ATTACHMENT_REFS_LENGTH = 4000
MAX_ATTACHMENT_REF_LENGTH = 1000
PAYMENT_TERMINAL_PROTOCOL_STATUSES = frozenset({'yzf', 'sdfq', 'zky'})
PAYMENT_WRITABLE_SCHEDULE_STATUS = 'open'
HEIC_BRANDS = frozenset(
{b'heic', b'heix', b'hevc', b'hevx', b'heim', b'heis'}
)
HEIF_BRANDS = HEIC_BRANDS | frozenset({b'mif1', b'msf1'})
ALLOWED_MIME_BY_EXT = {
'jpg': {'image/jpeg'},
'jpeg': {'image/jpeg'},
'png': {'image/png'},
'webp': {'image/webp'},
'heic': {'image/heic', 'image/heif', 'application/octet-stream'},
'heif': {'image/heic', 'image/heif', 'application/octet-stream'},
'pdf': {'application/pdf'},
'doc': {'application/msword', 'application/octet-stream'},
'docx': {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/zip',
'application/octet-stream',
},
'xls': {'application/vnd.ms-excel', 'application/octet-stream'},
'xlsx': {
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip',
'application/octet-stream',
},
}
def _option(value: Any, label: Any) -> Dict[str, str]:
return {'value': str(value or ''), 'label': str(label or '')}
def _verify_identity(ctx: MarketFieldContext, user_info: dict) -> None:
if not isinstance(user_info, dict):
raise PermissionError('用户身份与现场范围上下文不一致')
if (
str(user_info.get('user_id') or '').strip() != ctx.user_id
or str(user_info.get('tenant_no') or '').strip() != ctx.tenant_no
):
raise PermissionError('用户身份与现场范围上下文不一致')
def authorized_region_ids(db: Session, ctx: MarketFieldContext) -> List[str]:
query = db.query(BizCollectionRegionStaff.region_id).filter(
BizCollectionRegionStaff.tenant_no == ctx.tenant_no,
BizCollectionRegionStaff.del_flag == 0,
BizCollectionRegionStaff.user_id == ctx.user_id,
)
if ctx.scope_kind == 'manager':
query = query.filter(BizCollectionRegionStaff.is_leader == 1)
return [str(row[0]) for row in query.distinct().all() if row and row[0]]
def _dict_options(db: Session, tenant_no: str, dict_type: str) -> List[dict]:
rows = (
db.query(SysDictData)
.filter(
SysDictData.tenant_no == tenant_no,
SysDictData.del_flag == 0,
SysDictData.dict_type == dict_type,
)
.order_by(SysDictData.order_num.asc(), SysDictData.dict_label.asc())
.all()
)
return [_option(row.dict_value, row.dict_label) for row in rows]
def _business_region_id(
db: Session,
*,
module: str,
business_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> Optional[str]:
"""仅从当前已在 scope 内的单据补充地区,不把 businessId 当越权入口。"""
record_id = str(business_id or '').strip()
if not record_id:
return None
if module == 'presign':
return str(
assert_presign_access(
db, record_id, ctx, user_info=user_info
).region_id
or ''
).strip() or None
if module != 'protocol':
return None
try:
assert_cell_protocol_access(db, record_id, ctx, user_info=user_info)
collection = (
db.query(BizProtocolCollectionInfo)
.filter(
BizProtocolCollectionInfo.collection_protocol_id == record_id,
BizProtocolCollectionInfo.tenant_no == ctx.tenant_no,
BizProtocolCollectionInfo.del_flag == 0,
)
.order_by(
BizProtocolCollectionInfo.create_time.desc(),
BizProtocolCollectionInfo.id.desc(),
)
.first()
)
except HTTPException:
assert_donation_access(db, record_id, ctx, user_info=user_info)
collection = (
db.query(BizResearchDonationCollection)
.filter(
BizResearchDonationCollection.research_donation_id == record_id,
BizResearchDonationCollection.tenant_no == ctx.tenant_no,
BizResearchDonationCollection.del_flag == 0,
)
.order_by(
BizResearchDonationCollection.create_time.desc(),
BizResearchDonationCollection.id.desc(),
)
.first()
)
return str(collection.region_id or '').strip() if collection else None
def get_form_options(
db: Session,
*,
module: str,
ctx: MarketFieldContext,
user_info: dict,
region_id: Optional[str] = None,
business_id: Optional[str] = None,
) -> Dict[str, List[dict]]:
"""返回移动表单最小选项集;不会返回联系方式或证件等 PII。"""
_verify_identity(ctx, user_info)
normalized_module = str(module or '').strip()
if normalized_module not in FORM_MODULES:
raise ValueError('不支持的表单选项模块')
if normalized_module in DICT_BY_MODULE:
dict_type, key = DICT_BY_MODULE[normalized_module]
return {key: _dict_options(db, ctx.tenant_no, dict_type)}
region_ids = authorized_region_ids(db, ctx)
document_region = _business_region_id(
db,
module=normalized_module,
business_id=str(business_id or ''),
ctx=ctx,
user_info=user_info,
)
if document_region and document_region not in region_ids:
region_ids.append(document_region)
selected_region = str(region_id or '').strip()
if selected_region and selected_region not in region_ids:
raise PermissionError('所选地区不在当前用户权限范围内')
hospital_regions = [selected_region] if selected_region else region_ids
regions = []
if region_ids:
regions = (
db.query(BizCollectionRegion)
.filter(
BizCollectionRegion.id.in_(region_ids),
BizCollectionRegion.tenant_no == ctx.tenant_no,
BizCollectionRegion.del_flag == 0,
)
.order_by(BizCollectionRegion.order_num.asc(), BizCollectionRegion.region_name.asc())
.all()
)
hospitals = []
staff_rows = []
if hospital_regions:
hospitals = (
db.query(BizCollectionHospital)
.filter(
BizCollectionHospital.region_id.in_(hospital_regions),
BizCollectionHospital.tenant_no == ctx.tenant_no,
BizCollectionHospital.del_flag == 0,
)
.order_by(BizCollectionHospital.order_num.asc(), BizCollectionHospital.hospital_name.asc())
.all()
)
staff_rows = (
db.query(SysUser, BizCollectionRegionStaff)
.join(
BizCollectionRegionStaff,
BizCollectionRegionStaff.user_id == SysUser.id,
)
.filter(
BizCollectionRegionStaff.region_id.in_(hospital_regions),
BizCollectionRegionStaff.tenant_no == ctx.tenant_no,
BizCollectionRegionStaff.del_flag == 0,
SysUser.tenant_no == ctx.tenant_no,
SysUser.del_flag == 0,
SysUser.status == '1',
)
.order_by(
BizCollectionRegionStaff.is_leader.desc(),
SysUser.nick_name.asc(),
SysUser.id.asc(),
)
.all()
)
sample_types = (
db.query(BizCellSampleType)
.filter(
BizCellSampleType.tenant_no == ctx.tenant_no,
BizCellSampleType.del_flag == 0,
)
.order_by(BizCellSampleType.order_num.asc(), BizCellSampleType.type_name.asc())
.all()
)
units = (
db.query(BizItemUnit)
.filter(BizItemUnit.tenant_no == ctx.tenant_no, BizItemUnit.del_flag == 0)
.order_by(BizItemUnit.order_num.asc(), BizItemUnit.unit_name.asc())
.all()
)
staff = []
seen_staff_ids = set()
for user, binding in staff_rows:
if user.id in seen_staff_ids:
continue
seen_staff_ids.add(user.id)
staff.append(
{
**_option(user.id, user.nick_name),
'isLeader': bool(binding.is_leader),
}
)
result = {
'regions': [_option(row.id, row.region_name) for row in regions],
'hospitals': [_option(row.id, row.hospital_name) for row in hospitals],
'staff': staff,
'sampleTypes': [_option(row.id, row.type_name) for row in sample_types],
'units': [_option(row.id, row.unit_name) for row in units],
}
if normalized_module == 'presign':
result.update(
{
'payModes': [
{'label': '趸交', 'value': 'dj'},
{'label': '分期', 'value': 'fq'},
],
'orderStatuses': [
{'label': '草稿', 'value': 'draft'},
{'label': '进行中', 'value': 'active'},
{'label': '已转正式', 'value': 'converted'},
{'label': '已作废', 'value': 'void'},
],
'transferTypes': _dict_options(db, ctx.tenant_no, 'zzfs'),
}
)
return result
def _validate_upload(
content: bytes,
filename: str,
content_type: str,
*,
max_bytes: int = MAX_UPLOAD_BYTES,
) -> tuple[str, str]:
name = str(filename or '').strip()
if not name or len(name) > 200 or re.search(r'[\x00-\x1f\x7f]', name):
raise ValueError('文件名无效')
if '/' in name or '\\' in name:
raise ValueError('文件名无效')
if not content:
raise ValueError('上传文件不能为空')
if len(content) > max_bytes:
raise ValueError(f'上传文件不能超过 {max_bytes // (1024 * 1024)}MiB')
ext = os.path.splitext(name)[1].lower().lstrip('.')
mime = str(content_type or '').lower().split(';', 1)[0].strip()
if ext not in ALLOWED_MIME_BY_EXT or mime not in ALLOWED_MIME_BY_EXT[ext]:
raise ValueError('不支持的文件类型')
signatures = {
'jpg': (b'\xff\xd8\xff',),
'jpeg': (b'\xff\xd8\xff',),
'png': (b'\x89PNG\r\n\x1a\n',),
'webp': (b'RIFF',),
'pdf': (b'%PDF-',),
'doc': (b'\xd0\xcf\x11\xe0',),
'xls': (b'\xd0\xcf\x11\xe0',),
'docx': (b'PK\x03\x04',),
'xlsx': (b'PK\x03\x04',),
}
if ext not in {'heic', 'heif'} and not any(
content.startswith(prefix) for prefix in signatures[ext]
):
raise ValueError('文件内容与扩展名不匹配')
if ext == 'webp' and (len(content) < 12 or content[8:12] != b'WEBP'):
raise ValueError('文件内容与扩展名不匹配')
if ext in {'heic', 'heif'}:
if len(content) < 16 or content[4:8] != b'ftyp':
raise ValueError('文件内容与扩展名不匹配')
box_size = int.from_bytes(content[:4], byteorder='big', signed=False)
if (
box_size < 16
or box_size > len(content)
or (box_size - 16) % 4 != 0
):
raise ValueError('文件内容与扩展名不匹配')
brands = {content[8:12]}
brands.update(
content[offset : offset + 4]
for offset in range(16, box_size, 4)
)
allowed_brands = HEIC_BRANDS if ext == 'heic' else HEIF_BRANDS
if not brands.intersection(allowed_brands):
raise ValueError('文件内容与扩展名不匹配')
if ext in {'docx', 'xlsx'}:
try:
with zipfile.ZipFile(BytesIO(content)) as archive:
names = set(archive.namelist())
except (OSError, zipfile.BadZipFile):
raise ValueError('文件内容与扩展名不匹配')
required_prefix = 'word/' if ext == 'docx' else 'xl/'
if '[Content_Types].xml' not in names or not any(
name.startswith(required_prefix) for name in names
):
raise ValueError('文件内容与扩展名不匹配')
return ext, mime
def _relative_file_url(full_url: str) -> str:
base_url = (settings.MINIO_BASE_URL or '').rstrip('/')
if base_url and full_url.startswith(base_url + '/'):
return full_url[len(base_url) + 1 :]
return full_url
def _file_size_display(size: int) -> str:
if size < 1024:
return f'{size} B'
if size < 1024 * 1024:
return f'{size / 1024:.1f} KB'
return f'{size / (1024 * 1024):.1f} MB'
def save_market_attachment(
db: Session,
*,
content: bytes,
filename: str,
content_type: str,
module: str,
ctx: MarketFieldContext,
user_info: dict,
storage_backend=storage,
) -> Dict[str, Any]:
_verify_identity(ctx, user_info)
normalized_module = str(module or '').strip()
if normalized_module not in UPLOAD_MODULES:
raise ValueError('不支持的附件模块')
ext, mime = _validate_upload(
content,
filename,
content_type,
max_bytes=(
PRESIGN_MAX_UPLOAD_BYTES
if normalized_module == 'presign'
else MAX_UPLOAD_BYTES
),
)
folder = f'market/{ctx.scope_kind}/{normalized_module}'
full_url = storage_backend.upload_file(content, filename, folder)
file_url = _relative_file_url(full_url)
row = BizAttachment(
id=uuid.uuid4().hex,
business_type=f'market:{ctx.scope_kind}:{normalized_module}:staging',
business_id=None,
original_name=filename,
file_ext=ext,
file_url=file_url,
file_size=len(content),
file_type=mime,
tenant_no=ctx.tenant_no,
create_by=ctx.user_id,
create_dept_id=str(
user_info.get('dept_id')
or user_info.get('deptId')
or user_info.get('region_id')
or ''
).strip(),
del_flag=0,
)
committed = False
try:
db.add(row)
db.flush()
db.refresh(row)
result = {
'id': row.id,
'fileName': row.original_name,
'fileUrl': row.file_url,
'fileSize': row.file_size,
'fileSizeDisplay': _file_size_display(row.file_size or 0),
'fileType': row.file_type,
}
db.commit()
committed = True
except Exception:
if not committed:
db.rollback()
try:
storage_backend.delete_file(full_url)
except Exception:
pass
raise
return result
def _split_refs(refs: Any) -> List[str]:
if refs is None:
return []
if isinstance(refs, str):
if len(refs) > MAX_ATTACHMENT_REFS_LENGTH:
raise ValueError('附件引用总长度超出限制')
raw: Iterable[Any] = refs.split(',')
elif isinstance(refs, (list, tuple, set)):
raw = refs
else:
raise ValueError('附件引用格式无效')
raw = list(raw)
if len(raw) > MAX_ATTACHMENT_REFS:
raise ValueError('附件数量不能超过 20 个')
values = []
total_length = 0
for item in raw:
if isinstance(item, dict):
item = item.get('id') or item.get('fileUrl') or item.get('url')
value = str(item or '').strip()
if len(value) > MAX_ATTACHMENT_REF_LENGTH:
raise ValueError('附件引用长度无效')
total_length += len(value)
if total_length + max(0, len(raw) - 1) > MAX_ATTACHMENT_REFS_LENGTH:
raise ValueError('附件引用总长度超出限制')
if value and value not in values:
values.append(value)
return values
def _attachment_candidates(value: str) -> List[str]:
candidates = [value]
base = (settings.MINIO_BASE_URL or '').rstrip('/')
if base and value.startswith(base + '/'):
candidates.append(value[len(base) + 1 :])
return list(dict.fromkeys(candidates))
def validate_attachment_refs(
db: Session,
refs: Any,
*,
module: str,
ctx: MarketFieldContext,
creating: bool,
existing_refs: Any = None,
business_id: Optional[str] = None,
) -> str:
"""批量锁定并解析附件;有 business_id 时原子消费当前角色暂存记录。"""
normalized_module = str(module or '').strip()
if normalized_module not in UPLOAD_MODULES:
raise ValueError('不支持的附件模块')
values = _split_refs(refs)
if not values:
return ''
existing = set(_split_refs(existing_refs)) if not creating else set()
new_values = [value for value in values if value not in existing]
if not new_values:
return ','.join(values)
normalized_business_id = str(business_id or '').strip()
candidates_by_value = {
value: _attachment_candidates(value) for value in new_values
}
all_candidates = {
candidate
for candidates in candidates_by_value.values()
for candidate in candidates
}
rows = (
db.query(BizAttachment)
.filter(
BizAttachment.tenant_no == ctx.tenant_no,
BizAttachment.del_flag == 0,
or_(
BizAttachment.id.in_(new_values),
BizAttachment.file_url.in_(all_candidates),
),
)
.with_for_update()
.all()
)
by_id = {str(row.id): row for row in rows}
by_url = {str(row.file_url): row for row in rows}
expected_staging = f'market:{ctx.scope_kind}:{normalized_module}:staging'
expected_bound = f'market:{ctx.scope_kind}:{normalized_module}:bound'
resolved = []
seen_row_ids = set()
for value in values:
if value in existing:
if value not in resolved:
resolved.append(value)
continue
row = by_id.get(value)
if row is None:
row = next(
(
by_url.get(candidate)
for candidate in candidates_by_value[value]
if by_url.get(candidate)
),
None,
)
if row is None:
raise ValueError('附件不存在或无权引用')
if row.id in seen_row_ids:
continue
seen_row_ids.add(row.id)
is_current_staging = (
row.business_type == expected_staging
and row.create_by == ctx.user_id
and not row.business_id
)
is_same_binding = (
bool(normalized_business_id)
and row.business_type == expected_bound
and str(row.business_id or '') == normalized_business_id
)
if not (is_current_staging or is_same_binding):
raise ValueError('附件不存在、已被绑定或无权引用')
if normalized_business_id and is_current_staging:
row.business_type = expected_bound
row.business_id = normalized_business_id
db.add(row)
resolved.append(str(row.file_url))
if normalized_business_id:
db.flush()
return ','.join(resolved)
def allowed_actions(
module: str,
*,
status: str,
scope_kind: str = 'specialist',
source: str = '',
has_sample_code: bool = False,
has_payment_permission: bool = False,
) -> List[str]:
"""纯函数动作白名单;路由仍必须执行对应权限与状态校验。"""
module, state = str(module or '').strip(), str(status or '').strip().lower()
if module in {'presign-list', 'protocol-list', 'freight-list'}:
return ['create']
if module == 'presign':
if state == 'converted':
return ['edit']
if state == 'void':
return ['edit', 'delete']
return ['edit', 'convert', 'delete']
if module == 'presign-transfer':
if state == 'dfj':
return ['edit', 'delete', 'submit']
if state == 'jfqrz':
return ['withdraw']
return []
if module == 'protocol':
return ['edit'] if state in {'', 'draft', 'dsx', 'dqr', 'sxz', 'ygq'} else []
if module == 'payment':
return [] if state in PAYMENT_TERMINAL_PROTOCOL_STATUSES else ['register-transfer']
if module == 'payment-transfer':
if state == 'dfj':
return ['edit-transfer', 'submit-finance']
if state == 'jfqrz':
return ['withdraw-transfer']
return []
if module == 'payment-chase':
return ['register-transfer'] if has_payment_permission else []
if module == 'sample':
if source == 'cell_storage' and has_sample_code:
return ['storage-cert', 'insurance']
return []
if module == 'freight':
return ['edit'] if scope_kind == 'manager' else []
return []
def decorate_allowed_actions(
module: str,
item: Dict[str, Any],
*,
scope_kind: str,
has_payment_permission: bool = False,
detail: bool = False,
) -> Dict[str, Any]:
"""为移动列表/详情 DTO 增加动作白名单,不把动作判断交给客户端。"""
data = dict(item)
basic = data.get('basic') if isinstance(data.get('basic'), dict) else {}
status = str(
data.get('orderStatus')
or data.get('protocolStatus')
or data.get('donationStatus')
or data.get('status')
or basic.get('protocolStatus')
or basic.get('donationStatus')
or basic.get('status')
or ''
)
if module == 'sample':
card = data.get('card') if isinstance(data.get('card'), dict) else data
source = str(card.get('source') or data.get('source') or '')
sample_code = str(card.get('sampleCode') or data.get('sampleCode') or '')
data['allowedActions'] = allowed_actions(
module,
status=status,
scope_kind=scope_kind,
source=source,
has_sample_code=bool(sample_code.strip()),
)
return data
if module == 'payment':
parent_writable = status.lower() not in PAYMENT_TERMINAL_PROTOCOL_STATUSES
schedules = []
has_writable_schedule = False
for schedule in data.get('schedules') or []:
schedule_data = dict(schedule)
schedule_writable = (
parent_writable
and str(schedule_data.get('scheduleStatus') or '').lower()
== PAYMENT_WRITABLE_SCHEDULE_STATUS
)
schedule_data['allowedActions'] = (
['register-transfer'] if schedule_writable else []
)
has_writable_schedule = has_writable_schedule or schedule_writable
transfers = []
for transfer in schedule_data.get('transfers') or []:
transfer_data = dict(transfer)
transfer_data['allowedActions'] = (
allowed_actions(
'payment-transfer',
status=str(transfer_data.get('payStatus') or ''),
)
if schedule_writable
else []
)
transfers.append(transfer_data)
schedule_data['transfers'] = transfers
schedules.append(schedule_data)
data['schedules'] = schedules
data['allowedActions'] = (
allowed_actions(module, status=status)
if parent_writable and has_writable_schedule
else []
)
return data
if module == 'presign' and detail:
transfers = []
for transfer in data.get('transfers') or []:
transfer_data = dict(transfer)
transfer_data['allowedActions'] = allowed_actions(
'presign-transfer',
status=str(transfer_data.get('payStatus') or ''),
)
transfers.append(transfer_data)
data['transfers'] = transfers
if module == 'freight' and detail:
provided = data.get('allowedActions')
safe_actions = {'edit', 'link', 'unlink'}
data['allowedActions'] = (
[action for action in provided if action in safe_actions]
if isinstance(provided, list)
else ['link', 'unlink']
)
return data
data['allowedActions'] = allowed_actions(
module,
status=status,
scope_kind=scope_kind,
has_payment_permission=has_payment_permission,
)
return data
+23 -4
View File
@@ -2037,7 +2037,7 @@ def test_conversion_unique_index_migration_is_mysql57_idempotent():
assert "unique index uk_collection_protocol_presign_source" in normalized
def test_market_presign_routes_are_fixed_and_do_not_expose_delete_or_finance_actions():
def test_market_presign_routes_are_fixed_and_expose_scoped_transfer_actions():
from app.api.market_mobile import router
actual = {
@@ -2053,13 +2053,32 @@ def test_market_presign_routes_are_fixed_and_do_not_expose_delete_or_finance_act
{
("GET", base),
("POST", base),
("GET", f"{base}/form-options"),
("GET", f"{base}/form-options"),
("GET", f"{base}/{{order_id}}"),
("PUT", f"{base}/{{order_id}}"),
("DELETE", f"{base}/{{order_id}}"),
("POST", f"{base}/{{order_id}}/convert"),
("POST", f"{base}/{{order_id}}/transfers"),
}
)
assert actual == expected
assert all(method != "DELETE" for method, _ in actual)
assert all("finance" not in path for _, path in actual)
transfer_actual = {
(method, route.path)
for route in router.routes
if "/presign-transfers" in route.path
for method in route.methods
}
transfer_expected = set()
for role in ("specialist", "manager"):
base = f"/mini/market/{role}/presign-transfers/{{transfer_id}}"
transfer_expected.update(
{
("PUT", base),
("DELETE", base),
("POST", f"{base}/submit"),
("POST", f"{base}/withdraw"),
}
)
assert transfer_actual == transfer_expected
+962
View File
@@ -0,0 +1,962 @@
import asyncio
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.base import Base
from app.models.models import (
BizAttachment,
BizCellSampleType,
BizCollectionHospital,
BizCollectionRegion,
BizCollectionRegionStaff,
BizItemUnit,
SysDictData,
SysUser,
)
from app.services.market_field_scope import MarketFieldContext
def _session():
engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(
engine,
tables=[
BizAttachment.__table__,
BizCellSampleType.__table__,
BizCollectionHospital.__table__,
BizCollectionRegion.__table__,
BizCollectionRegionStaff.__table__,
BizItemUnit.__table__,
SysDictData.__table__,
SysUser.__table__,
],
)
return sessionmaker(bind=engine)()
def _ctx(scope='specialist', user_id='user-a'):
return MarketFieldContext('tenant-a', user_id, scope)
def _user(user_id='user-a'):
return {
'user_id': user_id,
'tenant_no': 'tenant-a',
'region_id': 'dept-a',
}
def _seed_options(db):
db.add_all(
[
BizCollectionRegion(id='ra', region_name='A区', tenant_no='tenant-a', create_dept_id='', del_flag=0),
BizCollectionRegion(id='rb', region_name='B区', tenant_no='tenant-a', create_dept_id='', del_flag=0),
BizCollectionRegion(id='rx', region_name='跨租户秘密区', tenant_no='tenant-b', create_dept_id='', del_flag=0),
BizCollectionHospital(id='ha', region_id='ra', hospital_name='A院', tenant_no='tenant-a', create_dept_id='', del_flag=0),
BizCollectionHospital(id='hb', region_id='rb', hospital_name='B院', tenant_no='tenant-a', create_dept_id='', del_flag=0),
BizCollectionHospital(id='hx', region_id='rx', hospital_name='跨租户秘密院', tenant_no='tenant-b', create_dept_id='', del_flag=0),
BizCollectionRegionStaff(id='rsa', region_id='ra', user_id='user-a', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0),
BizCollectionRegionStaff(id='rsb', region_id='rb', user_id='user-b', is_leader=1, tenant_no='tenant-a', create_dept_id='', del_flag=0),
BizCollectionRegionStaff(id='rsa2', region_id='ra', user_id='staff-a', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0),
BizCollectionRegionStaff(id='rsa3', region_id='ra', user_id='leader-a', is_leader=1, tenant_no='tenant-a', create_dept_id='', del_flag=0),
BizCollectionRegionStaff(id='rsx', region_id='rx', user_id='cross-user', is_leader=0, tenant_no='tenant-b', create_dept_id='', del_flag=0),
SysUser(id='user-a', user_no='ua', nick_name='本人', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
SysUser(id='user-b', user_no='ub', nick_name='B经理', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
SysUser(id='staff-a', user_no='sa', nick_name='A员工', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
SysUser(id='leader-a', user_no='la', nick_name='A负责人', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
SysUser(id='disabled', user_no='off', nick_name='停用秘密', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=0),
SysUser(id='cross-user', user_no='xx', nick_name='跨租户秘密用户', status='1', tenant_no='tenant-b', create_dept_id='', del_flag=0),
BizCellSampleType(id='type-a', type_code='A', type_name='样本A', tenant_no='tenant-a', create_dept_id='', del_flag=0),
BizCellSampleType(id='type-x', type_code='X', type_name='跨租户秘密样本', tenant_no='tenant-b', create_dept_id='', del_flag=0),
BizItemUnit(id='unit-a', unit_code='ml', unit_name='毫升', tenant_no='tenant-a', create_dept_id='', del_flag=0),
SysDictData(id='dict-pay', dict_label='微信', dict_value='wx', dict_type='zzfs', tenant_no='tenant-a', create_dept_id='', del_flag=0),
SysDictData(id='dict-freight', dict_label='专线车', dict_value='zxc', dict_type='sample_transport_mode', tenant_no='tenant-a', create_dept_id='', del_flag=0),
SysDictData(id='dict-secret', dict_label='不应泄漏', dict_value='secret', dict_type='unrelated', tenant_no='tenant-a', create_dept_id='', del_flag=0),
]
)
db.commit()
def test_specialist_form_options_are_region_scoped_and_minimal(monkeypatch):
from app.services.market_support import get_form_options
db = _session()
_seed_options(db)
out = get_form_options(db, module='presign', ctx=_ctx(), user_info=_user())
assert out['regions'] == [{'value': 'ra', 'label': 'A区'}]
assert out['hospitals'] == [{'value': 'ha', 'label': 'A院'}]
assert out['staff'] == [
{'value': 'leader-a', 'label': 'A负责人', 'isLeader': True},
{'value': 'staff-a', 'label': 'A员工', 'isLeader': False},
{'value': 'user-a', 'label': '本人', 'isLeader': False},
]
assert out['sampleTypes'] == [{'value': 'type-a', 'label': '样本A'}]
assert out['units'] == [{'value': 'unit-a', 'label': '毫升'}]
assert out['payModes'] == [
{'label': '趸交', 'value': 'dj'},
{'label': '分期', 'value': 'fq'},
]
assert out['orderStatuses'] == [
{'label': '草稿', 'value': 'draft'},
{'label': '进行中', 'value': 'active'},
{'label': '已转正式', 'value': 'converted'},
{'label': '已作废', 'value': 'void'},
]
assert out['transferTypes'] == [{'value': 'wx', 'label': '微信'}]
assert '跨租户' not in str(out)
monkeypatch.setattr(
'app.services.market_support._business_region_id',
lambda *args, **kwargs: 'rb',
)
supplemented = get_form_options(
db,
module='presign',
ctx=_ctx(),
user_info=_user(),
business_id='own-document',
)
assert supplemented['regions'] == [
{'value': 'ra', 'label': 'A区'},
{'value': 'rb', 'label': 'B区'},
]
def test_manager_form_options_only_include_led_regions_and_requested_region_must_be_authorized():
from app.services.market_support import get_form_options
db = _session()
_seed_options(db)
out = get_form_options(
db,
module='protocol',
ctx=_ctx('manager', 'user-b'),
user_info=_user('user-b'),
region_id='rb',
)
assert out['regions'] == [{'value': 'rb', 'label': 'B区'}]
assert out['hospitals'] == [{'value': 'hb', 'label': 'B院'}]
with pytest.raises(PermissionError, match='地区'):
get_form_options(
db,
module='protocol',
ctx=_ctx('manager', 'user-b'),
user_info=_user('user-b'),
region_id='ra',
)
def test_payment_and_freight_options_use_fixed_dictionary_allowlist():
from app.services.market_support import get_form_options
db = _session()
_seed_options(db)
payment = get_form_options(db, module='payment', ctx=_ctx(), user_info=_user())
freight = get_form_options(db, module='freight', ctx=_ctx(), user_info=_user())
assert payment == {'transferTypes': [{'value': 'wx', 'label': '微信'}]}
assert freight == {'transportModes': [{'value': 'zxc', 'label': '专线车'}]}
assert '不应泄漏' not in str(payment) + str(freight)
class _Storage:
def __init__(self):
self.uploaded = []
self.deleted = []
def upload_file(self, content, filename, folder):
self.uploaded.append((content, filename, folder))
return f'https://files.test/{folder}/generated.pdf'
def delete_file(self, path):
self.deleted.append(path)
return True
def test_upload_uses_fixed_folder_and_records_current_tenant_and_uploader():
from app.services.market_support import save_market_attachment
db = _session()
backend = _Storage()
result = save_market_attachment(
db,
content=b'%PDF-1.7 safe',
filename='proof.pdf',
content_type='application/pdf',
module='payment',
ctx=_ctx(),
user_info=_user(),
storage_backend=backend,
)
assert backend.uploaded[0][2] == 'market/specialist/payment'
row = db.query(BizAttachment).one()
assert (row.tenant_no, row.create_by, row.create_dept_id) == ('tenant-a', 'user-a', 'dept-a')
assert row.business_type == 'market:specialist:payment:staging'
assert result['id'] == row.id
assert result['fileUrl'] == row.file_url
def test_presign_upload_allows_20_mib_while_other_modules_remain_at_10_mib():
from app.services.market_support import save_market_attachment
content = b'%PDF-' + b'a' * (10 * 1024 * 1024)
backend = _Storage()
result = save_market_attachment(
_session(),
content=content,
filename='presign-proof.pdf',
content_type='application/pdf',
module='presign',
ctx=_ctx(),
user_info=_user(),
storage_backend=backend,
)
assert result['fileSize'] == len(content)
with pytest.raises(ValueError, match='10MiB'):
save_market_attachment(
_session(),
content=content,
filename='payment-proof.pdf',
content_type='application/pdf',
module='payment',
ctx=_ctx(),
user_info=_user(),
storage_backend=_Storage(),
)
@pytest.mark.parametrize(
('content', 'filename', 'content_type'),
[
(b'', 'empty.pdf', 'application/pdf'),
(b'<svg/>', 'bad.svg', 'image/svg+xml'),
(b'MZ', 'bad.exe', 'application/octet-stream'),
(b'not an image', 'fake.jpg', 'image/jpeg'),
(b'%PDF-1.7', 'fake.jpg', 'image/jpeg'),
(b'\x00\x00\x00\x18junk-ftyp-heic-data', 'fake.heic', 'image/heic'),
(b'\x00\x00\x00\x18ftypzzzz\x00\x00\x00\x00zzzz', 'fake.heic', 'image/heic'),
(b'\x00\x00\x00\xffftypheic\x00\x00\x00\x00', 'fake.heic', 'image/heic'),
(b'\x00\x00\x00\x0cftypheic', 'short.heic', 'image/heic'),
(b'\x00\x00\x00\x0fftypheicabc', 'short.heic', 'image/heic'),
(b'\x00\x00\x00\x15ftypheic\x00\x00\x00\x00mif1x', 'misaligned.heic', 'image/heic'),
(b'\x00\x00\x00\x10ftypmif1\x00\x00\x00\x00', 'generic-only.heic', 'image/heic'),
(b'PK\x03\x04not-a-docx', 'fake.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
(b'a' * (10 * 1024 * 1024 + 1), 'huge.pdf', 'application/pdf'),
],
)
def test_upload_rejects_empty_oversized_or_unsafe_files(content, filename, content_type):
from app.services.market_support import save_market_attachment
db = _session()
backend = _Storage()
with pytest.raises(ValueError):
save_market_attachment(
db,
content=content,
filename=filename,
content_type=content_type,
module='payment',
ctx=_ctx(),
user_info=_user(),
storage_backend=backend,
)
assert backend.uploaded == []
def test_upload_deletes_storage_object_when_database_commit_fails(monkeypatch):
from app.services.market_support import save_market_attachment
db = _session()
backend = _Storage()
monkeypatch.setattr(db, 'commit', lambda: (_ for _ in ()).throw(RuntimeError('db down')))
with pytest.raises(RuntimeError, match='db down'):
save_market_attachment(
db,
content=b'%PDF-1.7 safe',
filename='proof.pdf',
content_type='application/pdf',
module='protocol',
ctx=_ctx(),
user_info=_user(),
storage_backend=backend,
)
assert backend.deleted == ['https://files.test/market/specialist/protocol/generated.pdf']
def test_upload_never_refreshes_after_commit_or_deletes_committed_object(monkeypatch):
from app.services.market_support import save_market_attachment
db = _session()
backend = _Storage()
committed = False
original_commit = db.commit
original_refresh = db.refresh
def tracked_commit():
nonlocal committed
original_commit()
committed = True
def refresh_only_before_commit(row):
if committed:
raise RuntimeError('refresh after commit is unsafe')
return original_refresh(row)
monkeypatch.setattr(db, 'commit', tracked_commit)
monkeypatch.setattr(db, 'refresh', refresh_only_before_commit)
result = save_market_attachment(
db,
content=b'%PDF-1.7 safe',
filename='proof.pdf',
content_type='application/pdf',
module='protocol',
ctx=_ctx(),
user_info=_user(),
storage_backend=backend,
)
assert result['id']
assert backend.deleted == []
def test_upload_accepts_well_formed_heif_ftyp_box_with_allowed_brand():
from app.services.market_support import save_market_attachment
db = _session()
backend = _Storage()
content = b'\x00\x00\x00\x14ftypheic\x00\x00\x00\x00mif1'
result = save_market_attachment(
db,
content=content,
filename='photo.heic',
content_type='image/heic',
module='sample',
ctx=_ctx(),
user_info=_user(),
storage_backend=backend,
)
assert result['fileType'] == 'image/heic'
assert backend.uploaded[0][0] == content
def test_heif_extension_accepts_generic_heif_brand():
from app.services.market_support import save_market_attachment
db = _session()
backend = _Storage()
content = b'\x00\x00\x00\x10ftypmif1\x00\x00\x00\x00'
result = save_market_attachment(
db,
content=content,
filename='photo.heif',
content_type='image/heif',
module='sample',
ctx=_ctx(),
user_info=_user(),
storage_backend=backend,
)
assert result['fileType'] == 'image/heif'
def _attachment(**overrides):
values = dict(
id='att-a',
business_type='market:specialist:payment:staging',
business_id=None,
original_name='proof.pdf',
file_ext='pdf',
file_url='market/specialist/payment/a.pdf',
file_size=12,
file_type='application/pdf',
tenant_no='tenant-a',
create_by='user-a',
create_dept_id='',
del_flag=0,
)
values.update(overrides)
return BizAttachment(**values)
def test_create_attachment_refs_require_current_uploader_staging_exact_match():
from app.services.market_support import validate_attachment_refs
db = _session()
db.add_all(
[
_attachment(),
_attachment(id='att-other', file_url='market/specialist/payment/other.pdf', create_by='other'),
_attachment(id='att-cross', file_url='market/specialist/payment/cross.pdf', tenant_no='tenant-b'),
]
)
db.commit()
assert validate_attachment_refs(
db,
'att-a,market/specialist/payment/a.pdf',
module='payment',
ctx=_ctx(),
creating=True,
) == 'market/specialist/payment/a.pdf'
for invalid in ('att-other', 'att-cross', 'https://evil.test/a.pdf', 'a.pdf'):
with pytest.raises(ValueError, match='附件'):
validate_attachment_refs(db, invalid, module='payment', ctx=_ctx(), creating=True)
def test_staging_attachment_must_match_current_scope_role_exactly():
from app.services.market_support import validate_attachment_refs
db = _session()
db.add(
_attachment(
id='manager-staging',
business_type='market:manager:payment:staging',
file_url='market/manager/payment/proof.pdf',
)
)
db.commit()
with pytest.raises(ValueError, match='附件'):
validate_attachment_refs(
db,
'manager-staging',
module='payment',
ctx=_ctx('specialist'),
creating=True,
business_id='pay-a',
)
def test_attachment_refs_bind_once_to_exact_scope_module_and_business():
from app.services.market_support import validate_attachment_refs
db = _session()
row = _attachment()
db.add(row)
db.commit()
result = validate_attachment_refs(
db,
'att-a,market/specialist/payment/a.pdf',
module='payment',
ctx=_ctx(),
creating=True,
business_id='transfer-a',
)
assert result == 'market/specialist/payment/a.pdf'
assert (row.business_type, row.business_id) == (
'market:specialist:payment:bound',
'transfer-a',
)
assert validate_attachment_refs(
db,
'att-a',
module='payment',
ctx=_ctx(),
creating=False,
business_id='transfer-a',
) == 'market/specialist/payment/a.pdf'
with pytest.raises(ValueError, match='附件'):
validate_attachment_refs(
db,
'att-a',
module='payment',
ctx=_ctx(),
creating=False,
business_id='transfer-b',
)
with pytest.raises(ValueError, match='附件'):
validate_attachment_refs(
db,
'att-a',
module='payment',
ctx=_ctx('manager'),
creating=False,
business_id='transfer-a',
)
def test_attachment_ref_resolution_uses_one_batched_query_and_deduplicates_aliases():
from sqlalchemy import event
from app.services.market_support import validate_attachment_refs
db = _session()
db.add_all(
[
_attachment(id='att-1', file_url='market/specialist/payment/1.pdf'),
_attachment(id='att-2', file_url='market/specialist/payment/2.pdf'),
]
)
db.commit()
statements = []
def capture(_conn, _cursor, statement, _parameters, _context, _many):
if 'biz_attachment' in statement.lower() and statement.lstrip().lower().startswith('select'):
statements.append(statement)
event.listen(db.get_bind(), 'before_cursor_execute', capture)
try:
result = validate_attachment_refs(
db,
'att-1,market/specialist/payment/1.pdf,att-2',
module='payment',
ctx=_ctx(),
creating=True,
business_id='transfer-batch',
)
finally:
event.remove(db.get_bind(), 'before_cursor_execute', capture)
assert result == 'market/specialist/payment/1.pdf,market/specialist/payment/2.pdf'
assert len(statements) == 1
@pytest.mark.parametrize(
'refs',
[','.join(f'att-{index}' for index in range(21)), 'x' * 4001],
)
def test_attachment_refs_reject_excessive_count_or_total_length(refs):
from app.services.market_support import validate_attachment_refs
with pytest.raises(ValueError, match='附件'):
validate_attachment_refs(
_session(),
refs,
module='payment',
ctx=_ctx(),
creating=True,
business_id='transfer-limit',
)
def test_all_mobile_attachment_schemas_cap_total_reference_length():
from pydantic import ValidationError
from app.schemas.market_mobile import (
MarketPaymentTransferBody,
MarketPresignTransferBody,
MarketProtocolAttachmentsBody,
MarketSampleStorageCertBody,
)
too_long = 'x' * 4001
payloads = (
(MarketPresignTransferBody, {'transferAttachments': too_long}),
(MarketProtocolAttachmentsBody, {'protocolAttachments': too_long}),
(MarketPaymentTransferBody, {'transferAttachments': too_long}),
(MarketSampleStorageCertBody, {'attachments': too_long}),
)
for schema, payload in payloads:
with pytest.raises(ValidationError):
schema.model_validate(payload)
def test_update_attachment_refs_allow_exact_existing_refs_and_current_user_new_only():
from app.services.market_support import validate_attachment_refs
db = _session()
db.add_all(
[
_attachment(
id='existing',
file_url='market/specialist/payment/existing.pdf',
create_by='legacy',
business_type='market:specialist:payment:bound',
business_id='transfer-existing',
),
_attachment(id='new', file_url='market/specialist/payment/new.pdf'),
]
)
db.commit()
out = validate_attachment_refs(
db,
'existing,new',
module='payment',
ctx=_ctx(),
creating=False,
existing_refs='market/specialist/payment/existing.pdf',
business_id='transfer-existing',
)
assert out == 'market/specialist/payment/existing.pdf,market/specialist/payment/new.pdf'
@pytest.mark.parametrize('metadata_kind', ['missing', 'legacy-unbound', 'other-role-bound'])
def test_update_may_retain_exact_server_supplied_existing_url_without_current_metadata(
metadata_kind,
):
from app.services.market_support import validate_attachment_refs
db = _session()
existing_url = 'legacy/payment/existing.pdf'
if metadata_kind == 'legacy-unbound':
db.add(
_attachment(
id='legacy-unbound',
file_url=existing_url,
business_type='protocol',
create_by='legacy-user',
)
)
elif metadata_kind == 'other-role-bound':
db.add(
_attachment(
id='specialist-bound',
file_url=existing_url,
business_type='market:specialist:payment:bound',
business_id='transfer-existing',
create_by='specialist-user',
)
)
db.commit()
assert validate_attachment_refs(
db,
existing_url,
module='payment',
ctx=_ctx('manager'),
creating=False,
existing_refs=existing_url,
business_id='transfer-existing',
) == existing_url
def test_existing_ref_exception_allows_removal_but_not_arbitrary_url_smuggling():
from app.services.market_support import validate_attachment_refs
db = _session()
assert validate_attachment_refs(
db,
'',
module='payment',
ctx=_ctx(),
creating=False,
existing_refs='legacy/payment/existing.pdf',
business_id='transfer-existing',
) == ''
with pytest.raises(ValueError, match='附件'):
validate_attachment_refs(
db,
'https://evil.test/smuggled.pdf',
module='payment',
ctx=_ctx(),
creating=False,
existing_refs='legacy/payment/existing.pdf',
business_id='transfer-existing',
)
def test_allowed_actions_are_state_scope_and_permission_aware():
from app.services.market_support import allowed_actions
assert allowed_actions('presign', status='active') == ['edit', 'convert', 'delete']
assert allowed_actions('presign', status='void') == ['edit', 'delete']
assert allowed_actions('presign', status='converted') == ['edit']
assert allowed_actions('presign-transfer', status='dfj') == ['edit', 'delete', 'submit']
assert allowed_actions('presign-transfer', status='jfqrz') == ['withdraw']
assert allowed_actions('presign-transfer', status='yjf') == []
assert allowed_actions('protocol', status='dsx') == ['edit']
assert allowed_actions('protocol', status='yzz') == []
assert allowed_actions('payment-transfer', status='dfj') == ['edit-transfer', 'submit-finance']
assert allowed_actions('payment-transfer', status='jfqrz') == ['withdraw-transfer']
assert allowed_actions('payment-chase', status='', has_payment_permission=False) == []
assert allowed_actions('payment-chase', status='', has_payment_permission=True) == ['register-transfer']
assert allowed_actions('sample', status='', source='research_donation', has_sample_code=True) == []
assert allowed_actions('sample', status='', source='cell_storage', has_sample_code=True) == ['storage-cert', 'insurance']
assert allowed_actions('freight', status='', scope_kind='specialist') == []
assert allowed_actions('freight', status='', scope_kind='manager') == ['edit']
assert allowed_actions('presign-list', status='') == ['create']
assert allowed_actions('protocol-list', status='') == ['create']
assert allowed_actions('freight-list', status='', scope_kind='specialist') == ['create']
def test_presign_actions_include_delete_and_nested_transfer_actions():
from app.services.market_support import decorate_allowed_actions
active = decorate_allowed_actions(
'presign',
{
'orderStatus': 'active',
'transfers': [
{'id': 't1', 'payStatus': 'dfj'},
{'id': 't2', 'payStatus': 'jfqrz'},
{'id': 't3', 'payStatus': 'yjf'},
],
},
scope_kind='specialist',
detail=True,
)
assert active['allowedActions'] == ['edit', 'convert', 'delete']
assert [row['allowedActions'] for row in active['transfers']] == [
['edit', 'delete', 'submit'],
['withdraw'],
[],
]
def test_decorate_allowed_actions_covers_nested_payment_transfers():
from app.services.market_support import decorate_allowed_actions
detail = {
'id': 'pay-a',
'schedules': [
{
'id': 'schedule-a',
'scheduleStatus': 'open',
'transfers': [
{'id': 'draft', 'payStatus': 'dfj'},
{'id': 'submitted', 'payStatus': 'jfqrz'},
{'id': 'done', 'payStatus': 'yjf'},
],
}
],
}
out = decorate_allowed_actions('payment', detail, scope_kind='specialist')
assert out['allowedActions'] == ['register-transfer']
assert out['schedules'][0]['allowedActions'] == ['register-transfer']
assert [row['allowedActions'] for row in out['schedules'][0]['transfers']] == [
['edit-transfer', 'submit-finance'],
['withdraw-transfer'],
[],
]
@pytest.mark.parametrize('protocol_status', ['yzf', 'sdfq', 'zky'])
def test_payment_terminal_protocol_removes_all_nested_write_actions(protocol_status):
from app.services.market_support import decorate_allowed_actions
detail = {
'id': 'pay-terminal',
'protocolStatus': protocol_status,
'schedules': [
{
'id': 'schedule-open',
'scheduleStatus': 'open',
'transfers': [
{'id': 'draft', 'payStatus': 'dfj'},
{'id': 'submitted', 'payStatus': 'jfqrz'},
],
}
],
}
out = decorate_allowed_actions('payment', detail, scope_kind='specialist')
assert out['allowedActions'] == []
assert out['schedules'][0]['allowedActions'] == []
assert [row['allowedActions'] for row in out['schedules'][0]['transfers']] == [[], []]
@pytest.mark.parametrize('schedule_status', ['cleared', 'void'])
def test_non_open_payment_schedule_removes_schedule_and_transfer_actions(schedule_status):
from app.services.market_support import decorate_allowed_actions
detail = {
'id': 'pay-closed',
'protocolStatus': 'sxz',
'schedules': [
{
'id': 'schedule-closed',
'scheduleStatus': schedule_status,
'transfers': [{'id': 'draft', 'payStatus': 'dfj'}],
}
],
}
out = decorate_allowed_actions('payment', detail, scope_kind='specialist')
assert out['allowedActions'] == []
assert out['schedules'][0]['allowedActions'] == []
assert out['schedules'][0]['transfers'][0]['allowedActions'] == []
def test_freight_detail_exposes_only_scope_safe_link_actions():
from app.services.market_support import decorate_allowed_actions
specialist = decorate_allowed_actions(
'freight',
{'id': 'f1', 'allowedActions': ['edit', 'link', 'unlink']},
scope_kind='specialist',
detail=True,
)
manager = decorate_allowed_actions(
'freight',
{'id': 'f1', 'allowedActions': ['link', 'unlink']},
scope_kind='manager',
detail=True,
)
assert specialist['allowedActions'] == ['edit', 'link', 'unlink']
assert manager['allowedActions'] == ['link', 'unlink']
def test_protocol_detail_action_reads_nested_basic_status():
from app.services.market_support import decorate_allowed_actions
active = decorate_allowed_actions(
'protocol', {'basic': {'protocolStatus': 'dsx'}}, scope_kind='specialist'
)
terminal = decorate_allowed_actions(
'protocol', {'basic': {'protocolStatus': 'yzf'}}, scope_kind='specialist'
)
assert active['allowedActions'] == ['edit']
assert terminal['allowedActions'] == []
def test_market_support_routes_are_fixed_and_registered_before_dynamic_details():
from app.api.market_mobile import router
paths = [route.path for route in router.routes]
for scope in ('specialist', 'manager'):
for path in (
f'/mini/market/{scope}/presign-orders/form-options',
f'/mini/market/{scope}/protocols/form-options',
f'/mini/market/{scope}/payments/form-options',
f'/mini/market/{scope}/samples/form-options',
f'/mini/market/{scope}/freights/form-options',
):
assert path in paths
for module in ('presign', 'protocol', 'payment', 'sample'):
upload_path = f'/mini/market/{scope}/{module}/attachments'
assert upload_path in paths
upload_route = next(route for route in router.routes if route.path == upload_path)
assert [param.name for param in upload_route.dependant.query_params] == []
assert paths.index(f'/mini/market/{scope}/presign-orders/form-options') < paths.index(
f'/mini/market/{scope}/presign-orders/{{order_id}}'
)
assert paths.index(f'/mini/market/{scope}/protocols/form-options') < paths.index(
f'/mini/market/{scope}/protocols/{{protocol_id}}'
)
assert paths.index(f'/mini/market/{scope}/samples/form-options') < paths.index(
f'/mini/market/{scope}/samples/{{sample_id}}'
)
def test_presign_write_routes_use_fixed_paths_and_methods():
from app.api.market_mobile import router
for scope in ('specialist', 'manager'):
expected = {
f'/mini/market/{scope}/presign-orders/{{order_id}}': {'GET', 'PUT', 'DELETE'},
f'/mini/market/{scope}/presign-orders/{{order_id}}/transfers': {'POST'},
f'/mini/market/{scope}/presign-transfers/{{transfer_id}}': {'PUT', 'DELETE'},
f'/mini/market/{scope}/presign-transfers/{{transfer_id}}/submit': {'POST'},
f'/mini/market/{scope}/presign-transfers/{{transfer_id}}/withdraw': {'POST'},
}
for path, methods in expected.items():
registered = {
method
for route in router.routes
if route.path == path
for method in route.methods
}
assert registered == methods
def test_presign_write_routes_map_permission_and_validation_errors(monkeypatch):
from app.api import market_mobile
from app.schemas.market_mobile import MarketPresignTransferBody
delete_route = next(
route
for route in market_mobile.router.routes
if route.path == '/mini/market/specialist/presign-orders/{order_id}'
and route.methods == {'DELETE'}
)
create_transfer_route = next(
route
for route in market_mobile.router.routes
if route.path
== '/mini/market/specialist/presign-orders/{order_id}/transfers'
)
monkeypatch.setattr(
market_mobile.market_presign,
'delete_presign_order',
lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError('forbidden')),
)
permission_result = asyncio.run(
delete_route.endpoint(order_id='o1', user=_user(), db=SimpleNamespace())
)
assert permission_result['code'] == 403
monkeypatch.setattr(
market_mobile.market_presign,
'create_presign_transfer',
lambda *args, **kwargs: (_ for _ in ()).throw(ValueError('invalid')),
)
validation_result = asyncio.run(
create_transfer_route.endpoint(
order_id='o1',
body=MarketPresignTransferBody(transferAmount=100),
user=_user(),
db=SimpleNamespace(),
)
)
assert validation_result['code'] == 400
def test_attachment_routes_read_only_their_module_upload_limit(monkeypatch):
from app.api import market_mobile
class TrackingUpload:
filename = 'proof.pdf'
content_type = 'application/pdf'
def __init__(self):
self.read_size = None
async def read(self, size):
self.read_size = size
return b'%PDF-safe'
async def close(self):
return None
monkeypatch.setattr(
market_mobile.market_support,
'save_market_attachment',
lambda *args, **kwargs: {'id': 'attachment'},
)
routes = {
route.path: route
for route in market_mobile.router.routes
if route.path in {
'/mini/market/specialist/presign/attachments',
'/mini/market/specialist/payment/attachments',
}
}
presign_file = TrackingUpload()
payment_file = TrackingUpload()
asyncio.run(
routes['/mini/market/specialist/presign/attachments'].endpoint(
file=presign_file, user=_user(), db=SimpleNamespace()
)
)
asyncio.run(
routes['/mini/market/specialist/payment/attachments'].endpoint(
file=payment_file, user=_user(), db=SimpleNamespace()
)
)
assert presign_file.read_size == 20 * 1024 * 1024 + 1
assert payment_file.read_size == 10 * 1024 * 1024 + 1