安全:增加测试批次上下文硬校验
This commit is contained in:
@@ -4,6 +4,7 @@ from typing import Literal
|
|||||||
|
|
||||||
from exceptions.exception import LoginException
|
from exceptions.exception import LoginException
|
||||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||||
|
from module_business.domain.test_batch_scope import TestBatchScope
|
||||||
|
|
||||||
# 定义上下文变量
|
# 定义上下文变量
|
||||||
# 存储当前请求的编译后的排除路由模式列表
|
# 存储当前请求的编译后的排除路由模式列表
|
||||||
@@ -12,6 +13,8 @@ current_exclude_patterns: ContextVar[
|
|||||||
] = ContextVar('current_exclude_patterns', default=None)
|
] = ContextVar('current_exclude_patterns', default=None)
|
||||||
# 存储当前用户信息
|
# 存储当前用户信息
|
||||||
current_user: ContextVar[CurrentUserModel | None] = ContextVar('current_user', default=None)
|
current_user: ContextVar[CurrentUserModel | None] = ContextVar('current_user', default=None)
|
||||||
|
# 存储当前请求已校验的测试批次作用域
|
||||||
|
current_test_batch_scope: ContextVar[TestBatchScope | None] = ContextVar('current_test_batch_scope', default=None)
|
||||||
|
|
||||||
|
|
||||||
class RequestContext:
|
class RequestContext:
|
||||||
@@ -69,6 +72,21 @@ class RequestContext:
|
|||||||
raise LoginException(data='', message='当前用户信息为空,请检查是否已登录')
|
raise LoginException(data='', message='当前用户信息为空,请检查是否已登录')
|
||||||
return _current_user
|
return _current_user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set_current_test_batch_scope(scope: TestBatchScope) -> Token:
|
||||||
|
"""
|
||||||
|
设置当前请求已校验的测试批次作用域
|
||||||
|
|
||||||
|
:param scope: 测试批次作用域
|
||||||
|
:return: 上下文变量令牌,用于重置
|
||||||
|
"""
|
||||||
|
return current_test_batch_scope.set(scope)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_current_test_batch_scope() -> TestBatchScope | None:
|
||||||
|
"""获取当前请求已校验的测试批次作用域。"""
|
||||||
|
return current_test_batch_scope.get()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def reset_current_exclude_patterns(token: Token) -> None:
|
def reset_current_exclude_patterns(token: Token) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -87,6 +105,11 @@ class RequestContext:
|
|||||||
"""
|
"""
|
||||||
current_user.reset(token)
|
current_user.reset(token)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reset_current_test_batch_scope(token: Token) -> None:
|
||||||
|
"""重置当前请求的测试批次作用域。"""
|
||||||
|
current_test_batch_scope.reset(token)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def clear_all() -> None:
|
def clear_all() -> None:
|
||||||
"""
|
"""
|
||||||
@@ -94,3 +117,4 @@ class RequestContext:
|
|||||||
"""
|
"""
|
||||||
current_exclude_patterns.set(None)
|
current_exclude_patterns.set(None)
|
||||||
current_user.set(None)
|
current_user.set(None)
|
||||||
|
current_test_batch_scope.set(None)
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ class ContextCleanupMiddleware(BaseHTTPMiddleware):
|
|||||||
"""
|
"""
|
||||||
在每个请求处理完成后清理上下文信息
|
在每个请求处理完成后清理上下文信息
|
||||||
"""
|
"""
|
||||||
response = await call_next(request)
|
try:
|
||||||
# 请求处理完成后清理所有上下文变量
|
return await call_next(request)
|
||||||
RequestContext.clear_all()
|
finally:
|
||||||
return response
|
RequestContext.clear_all()
|
||||||
|
|
||||||
|
|
||||||
def add_context_cleanup_middleware(app: FastAPI) -> None:
|
def add_context_cleanup_middleware(app: FastAPI) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from module_business.domain.enums import TestBatchStatus
|
||||||
|
from module_business.entity.do.test_batch_do import BizTestBatch, BizTestBatchUser
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchDao:
|
||||||
|
"""测试批次访问数据库操作层。"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_batch(cls, db: AsyncSession, batch_id: int) -> BizTestBatch | None:
|
||||||
|
result = await db.execute(select(BizTestBatch).where(BizTestBatch.batch_id == batch_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_batch_for_update(cls, db: AsyncSession, batch_id: int) -> BizTestBatch | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(BizTestBatch)
|
||||||
|
.where(BizTestBatch.batch_id == batch_id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def user_is_authorized(cls, db: AsyncSession, batch_id: int, user_id: int) -> bool:
|
||||||
|
result = await db.execute(
|
||||||
|
select(BizTestBatchUser.batch_id).where(
|
||||||
|
BizTestBatchUser.batch_id == batch_id,
|
||||||
|
BizTestBatchUser.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_available_batches(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
is_admin: bool,
|
||||||
|
) -> list[BizTestBatch]:
|
||||||
|
statement = select(BizTestBatch).where(BizTestBatch.status == TestBatchStatus.ACTIVE.value)
|
||||||
|
if not is_admin:
|
||||||
|
statement = statement.join(BizTestBatchUser).where(BizTestBatchUser.user_id == user_id)
|
||||||
|
result = await db.execute(statement.order_by(BizTestBatch.batch_id.desc()))
|
||||||
|
return list(result.scalars().all())
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from fastapi import Depends, Request, params
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from common.aspect.pre_auth import CurrentUserDependency
|
||||||
|
from common.context import RequestContext
|
||||||
|
from config.get_db import get_db
|
||||||
|
from exceptions.exception import ServiceWarning
|
||||||
|
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||||
|
from module_business.domain.test_batch_scope import TestBatchScope
|
||||||
|
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||||
|
|
||||||
|
|
||||||
|
def parse_optional_positive_batch_id(raw: str | None) -> int | None:
|
||||||
|
"""解析可选的测试批次请求头。"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
batch_id = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ServiceWarning('', '测试批次标识必须为正整数') from None
|
||||||
|
if batch_id <= 0:
|
||||||
|
raise ServiceWarning('', '测试批次标识必须为正整数')
|
||||||
|
return batch_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchScopeResolver:
|
||||||
|
"""将请求头解析为已验证的测试批次作用域。"""
|
||||||
|
|
||||||
|
def __init__(self, *, write: bool = False) -> None:
|
||||||
|
self.write = write
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: CurrentUserModel = CurrentUserDependency(),
|
||||||
|
) -> TestBatchScope:
|
||||||
|
raw = request.headers.get('X-Test-Batch-Id')
|
||||||
|
batch_id = parse_optional_positive_batch_id(raw)
|
||||||
|
scope = await TestBatchAccessService.resolve_scope(db, current_user, batch_id, write=self.write)
|
||||||
|
RequestContext.set_current_test_batch_scope(scope)
|
||||||
|
return scope
|
||||||
|
|
||||||
|
|
||||||
|
def TestBatchScopeDependency(*, write: bool = False) -> params.Depends: # noqa: N802
|
||||||
|
"""构造可配置读写语义的批次作用域依赖。"""
|
||||||
|
return Depends(TestBatchScopeResolver(write=write))
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from sqlalchemy.orm import InstrumentedAttribute
|
||||||
|
from sqlalchemy.sql.elements import ColumnElement
|
||||||
|
|
||||||
|
from exceptions.exception import ServiceException
|
||||||
|
from module_business.domain.enums import TestBatchStatus
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TestBatchScope:
|
||||||
|
"""当前请求可信任的数据批次作用域。"""
|
||||||
|
|
||||||
|
batch_id: int | None
|
||||||
|
mode: Literal['FORMAL', 'TEST']
|
||||||
|
status: TestBatchStatus | None
|
||||||
|
writable: bool
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def formal(cls) -> 'TestBatchScope':
|
||||||
|
return cls(batch_id=None, mode='FORMAL', status=None, writable=True)
|
||||||
|
|
||||||
|
|
||||||
|
def batch_scope_condition(column: InstrumentedAttribute, scope: TestBatchScope) -> ColumnElement[bool]:
|
||||||
|
"""生成正式数据或当前测试批次的查询条件。"""
|
||||||
|
return column.is_(None) if scope.batch_id is None else column == scope.batch_id
|
||||||
|
|
||||||
|
|
||||||
|
def assert_record_in_scope(record: Any, scope: TestBatchScope, label: str) -> None:
|
||||||
|
"""防止已加载记录跨数据模式或测试批次被使用。"""
|
||||||
|
if record is None or record.test_batch_id != scope.batch_id:
|
||||||
|
raise ServiceException('', f'{label}不属于当前数据模式或测试批次')
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from exceptions.exception import PermissionException
|
||||||
|
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||||
|
from module_business.dao.test_batch_dao import TestBatchDao
|
||||||
|
from module_business.domain.enums import TestBatchStatus
|
||||||
|
from module_business.domain.test_batch_scope import TestBatchScope
|
||||||
|
from module_business.entity.do.test_batch_do import BizTestBatch
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchAccessService:
|
||||||
|
"""测试批次作用域与管理权限校验。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_super_admin(current_user: CurrentUserModel) -> bool:
|
||||||
|
return current_user.user is not None and current_user.user.admin is True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _raise_unavailable() -> None:
|
||||||
|
raise PermissionException({'reason': 'TEST_BATCH_UNAVAILABLE'}, '当前测试批次不可用')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _raise_closed() -> None:
|
||||||
|
raise PermissionException({'reason': 'TEST_BATCH_UNAVAILABLE'}, '当前测试批次已关闭')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def assert_super_admin(cls, current_user: CurrentUserModel) -> None:
|
||||||
|
"""确保当前用户由超级管理员标记明确授信。"""
|
||||||
|
if not cls._is_super_admin(current_user):
|
||||||
|
raise PermissionException('', '仅超级管理员可执行测试批次管理操作')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def resolve_scope(
|
||||||
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
current_user: CurrentUserModel,
|
||||||
|
batch_id: int | None,
|
||||||
|
*,
|
||||||
|
write: bool = False,
|
||||||
|
) -> TestBatchScope:
|
||||||
|
"""解析并验证业务请求可使用的数据作用域。"""
|
||||||
|
if batch_id is None:
|
||||||
|
return TestBatchScope.formal()
|
||||||
|
|
||||||
|
batch = await TestBatchDao.get_batch(db, batch_id)
|
||||||
|
if batch is None or batch.status == TestBatchStatus.PURGED.value:
|
||||||
|
cls._raise_unavailable()
|
||||||
|
|
||||||
|
if not cls._is_super_admin(current_user):
|
||||||
|
user_id = current_user.user.user_id if current_user.user is not None else None
|
||||||
|
if user_id is None or not await TestBatchDao.user_is_authorized(db, batch_id, user_id):
|
||||||
|
cls._raise_unavailable()
|
||||||
|
|
||||||
|
if batch.status == TestBatchStatus.CLOSED.value:
|
||||||
|
if write:
|
||||||
|
cls._raise_closed()
|
||||||
|
cls._raise_unavailable()
|
||||||
|
if batch.status != TestBatchStatus.ACTIVE.value:
|
||||||
|
cls._raise_unavailable()
|
||||||
|
|
||||||
|
return TestBatchScope(
|
||||||
|
batch_id=batch_id,
|
||||||
|
mode='TEST',
|
||||||
|
status=TestBatchStatus.ACTIVE,
|
||||||
|
writable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def lock_active_batch(cls, db: AsyncSession, scope: TestBatchScope) -> BizTestBatch | None:
|
||||||
|
"""在写入前锁定并重新确认测试批次仍为 ACTIVE。"""
|
||||||
|
if scope.mode == 'FORMAL':
|
||||||
|
return None
|
||||||
|
|
||||||
|
batch = await TestBatchDao.get_batch_for_update(db, scope.batch_id)
|
||||||
|
if batch is None or batch.status == TestBatchStatus.PURGED.value:
|
||||||
|
cls._raise_unavailable()
|
||||||
|
if batch.status == TestBatchStatus.CLOSED.value:
|
||||||
|
cls._raise_closed()
|
||||||
|
if batch.status != TestBatchStatus.ACTIVE.value:
|
||||||
|
cls._raise_unavailable()
|
||||||
|
return batch
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
|
||||||
|
from module_business.domain.enums import TestBatchStatus
|
||||||
|
from module_business.domain.test_batch_scope import TestBatchScope
|
||||||
|
|
||||||
|
|
||||||
|
def make_current_user(
|
||||||
|
user_id: int = 7,
|
||||||
|
permissions: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
admin: bool = False,
|
||||||
|
) -> CurrentUserModel:
|
||||||
|
return CurrentUserModel(
|
||||||
|
permissions=permissions or [],
|
||||||
|
roles=[],
|
||||||
|
user=UserInfoModel(
|
||||||
|
userId=user_id,
|
||||||
|
userName='admin' if user_id == 1 else f'user-{user_id}',
|
||||||
|
admin=admin,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_admin() -> CurrentUserModel:
|
||||||
|
return make_current_user(1, ['*:*:*'], admin=True)
|
||||||
|
|
||||||
|
|
||||||
|
def make_test_scope(batch_id: int, status: str = 'ACTIVE', writable: bool = True) -> TestBatchScope:
|
||||||
|
return TestBatchScope(batch_id=batch_id, mode='TEST', status=TestBatchStatus(status), writable=writable)
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from fastapi import params
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.sql import select
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from common.context import RequestContext
|
||||||
|
from exceptions.exception import LoginException, PermissionException, ServiceException, ServiceWarning
|
||||||
|
from middlewares.context_middleware import ContextCleanupMiddleware
|
||||||
|
from module_business.dao.test_batch_dao import TestBatchDao
|
||||||
|
from module_business.dependency.test_batch_dependency import (
|
||||||
|
TestBatchScopeDependency,
|
||||||
|
TestBatchScopeResolver,
|
||||||
|
parse_optional_positive_batch_id,
|
||||||
|
)
|
||||||
|
from module_business.domain.enums import TestBatchStatus
|
||||||
|
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope, batch_scope_condition
|
||||||
|
from module_business.entity.do.master_do import BizWarehouse
|
||||||
|
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||||
|
from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope
|
||||||
|
|
||||||
|
|
||||||
|
def make_request(batch_id: str | None = None) -> Request:
|
||||||
|
headers = [] if batch_id is None else [(b'x-test-batch-id', batch_id.encode())]
|
||||||
|
return Request({'type': 'http', 'method': 'GET', 'path': '/', 'headers': headers})
|
||||||
|
|
||||||
|
|
||||||
|
def compile_sql(statement: object) -> str:
|
||||||
|
return ' '.join(
|
||||||
|
str(statement.compile(dialect=postgresql.dialect(), compile_kwargs={'literal_binds': True})).split()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchFixtureTest(unittest.TestCase):
|
||||||
|
def test_fixtures_set_admin_flag_explicitly(self) -> None:
|
||||||
|
self.assertIs(False, make_current_user(user_id=2, permissions=['*:*:*']).user.admin)
|
||||||
|
self.assertIs(True, make_admin().user.admin)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchScopeTest(unittest.TestCase):
|
||||||
|
def test_formal_scope_is_writable_without_batch_status(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
TestBatchScope(batch_id=None, mode='FORMAL', status=None, writable=True),
|
||||||
|
TestBatchScope.formal(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_batch_scope_condition_separates_formal_and_test_rows(self) -> None:
|
||||||
|
formal_statement = select(BizWarehouse.warehouse_id).where(
|
||||||
|
batch_scope_condition(BizWarehouse.test_batch_id, TestBatchScope.formal())
|
||||||
|
)
|
||||||
|
test_statement = select(BizWarehouse.warehouse_id).where(
|
||||||
|
batch_scope_condition(BizWarehouse.test_batch_id, make_test_scope(7))
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('biz_warehouse.test_batch_id IS NULL', compile_sql(formal_statement))
|
||||||
|
self.assertIn('biz_warehouse.test_batch_id = 7', compile_sql(test_statement))
|
||||||
|
|
||||||
|
def test_record_scope_assertion_accepts_only_matching_scope(self) -> None:
|
||||||
|
assert_record_in_scope(SimpleNamespace(test_batch_id=None), TestBatchScope.formal(), '仓库')
|
||||||
|
assert_record_in_scope(SimpleNamespace(test_batch_id=7), make_test_scope(7), '仓库')
|
||||||
|
|
||||||
|
for record in (None, SimpleNamespace(test_batch_id=None), SimpleNamespace(test_batch_id=8)):
|
||||||
|
with self.subTest(record=record), self.assertRaises(ServiceException) as raised:
|
||||||
|
assert_record_in_scope(record, make_test_scope(7), '仓库')
|
||||||
|
self.assertEqual('仓库不属于当前数据模式或测试批次', raised.exception.message)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchHeaderTest(unittest.TestCase):
|
||||||
|
def test_missing_header_selects_formal_mode(self) -> None:
|
||||||
|
self.assertIsNone(parse_optional_positive_batch_id(None))
|
||||||
|
|
||||||
|
def test_positive_integer_header_is_parsed(self) -> None:
|
||||||
|
self.assertEqual(7, parse_optional_positive_batch_id('7'))
|
||||||
|
|
||||||
|
def test_invalid_header_is_rejected(self) -> None:
|
||||||
|
for raw in ('', ' ', '0', '-1', '1.5', 'batch-7'):
|
||||||
|
with self.subTest(raw=raw), self.assertRaisesRegex(ServiceWarning, '测试批次标识必须为正整数'):
|
||||||
|
parse_optional_positive_batch_id(raw)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchAccessTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self) -> None:
|
||||||
|
self.db = AsyncMock(spec=AsyncSession)
|
||||||
|
|
||||||
|
async def test_permission_character_cannot_replace_super_admin_flag(self) -> None:
|
||||||
|
current_user = make_current_user(user_id=2, permissions=['*:*:*', 'system:testbatch:purge'])
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PermissionException, '仅超级管理员'):
|
||||||
|
TestBatchAccessService.assert_super_admin(current_user)
|
||||||
|
|
||||||
|
async def test_super_admin_flag_passes_hard_gate(self) -> None:
|
||||||
|
TestBatchAccessService.assert_super_admin(make_admin())
|
||||||
|
|
||||||
|
async def test_formal_mode_skips_batch_queries(self) -> None:
|
||||||
|
get_batch = AsyncMock()
|
||||||
|
user_is_authorized = AsyncMock()
|
||||||
|
with (
|
||||||
|
patch.object(TestBatchDao, 'get_batch', get_batch),
|
||||||
|
patch.object(TestBatchDao, 'user_is_authorized', user_is_authorized),
|
||||||
|
):
|
||||||
|
scope = await TestBatchAccessService.resolve_scope(self.db, make_current_user(), None, write=True)
|
||||||
|
|
||||||
|
self.assertEqual(TestBatchScope.formal(), scope)
|
||||||
|
get_batch.assert_not_awaited()
|
||||||
|
user_is_authorized.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_authorized_active_batch_resolves_writable_scope(self) -> None:
|
||||||
|
get_batch = AsyncMock(return_value=SimpleNamespace(batch_id=7, status='ACTIVE'))
|
||||||
|
user_is_authorized = AsyncMock(return_value=True)
|
||||||
|
with (
|
||||||
|
patch.object(TestBatchDao, 'get_batch', get_batch),
|
||||||
|
patch.object(TestBatchDao, 'user_is_authorized', user_is_authorized),
|
||||||
|
):
|
||||||
|
scope = await TestBatchAccessService.resolve_scope(self.db, make_current_user(8), 7, write=True)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
TestBatchScope(batch_id=7, mode='TEST', status=TestBatchStatus.ACTIVE, writable=True),
|
||||||
|
scope,
|
||||||
|
)
|
||||||
|
get_batch.assert_awaited_once_with(self.db, 7)
|
||||||
|
user_is_authorized.assert_awaited_once_with(self.db, 7, 8)
|
||||||
|
|
||||||
|
async def test_admin_active_batch_bypasses_user_mapping(self) -> None:
|
||||||
|
get_batch = AsyncMock(return_value=SimpleNamespace(batch_id=7, status='ACTIVE'))
|
||||||
|
user_is_authorized = AsyncMock()
|
||||||
|
with (
|
||||||
|
patch.object(TestBatchDao, 'get_batch', get_batch),
|
||||||
|
patch.object(TestBatchDao, 'user_is_authorized', user_is_authorized),
|
||||||
|
):
|
||||||
|
scope = await TestBatchAccessService.resolve_scope(self.db, make_admin(), 7, write=False)
|
||||||
|
|
||||||
|
self.assertEqual(make_test_scope(7), scope)
|
||||||
|
user_is_authorized.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_missing_batch_is_unavailable(self) -> None:
|
||||||
|
get_batch = AsyncMock(return_value=None)
|
||||||
|
user_is_authorized = AsyncMock()
|
||||||
|
with (
|
||||||
|
patch.object(TestBatchDao, 'get_batch', get_batch),
|
||||||
|
patch.object(TestBatchDao, 'user_is_authorized', user_is_authorized),
|
||||||
|
self.assertRaisesRegex(PermissionException, '当前测试批次不可用') as raised,
|
||||||
|
):
|
||||||
|
await TestBatchAccessService.resolve_scope(self.db, make_admin(), 7, write=False)
|
||||||
|
|
||||||
|
self.assertEqual({'reason': 'TEST_BATCH_UNAVAILABLE'}, raised.exception.data)
|
||||||
|
user_is_authorized.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_unauthorized_batch_is_unavailable(self) -> None:
|
||||||
|
get_batch = AsyncMock(return_value=SimpleNamespace(batch_id=7, status='ACTIVE'))
|
||||||
|
user_is_authorized = AsyncMock(return_value=False)
|
||||||
|
with (
|
||||||
|
patch.object(TestBatchDao, 'get_batch', get_batch),
|
||||||
|
patch.object(TestBatchDao, 'user_is_authorized', user_is_authorized),
|
||||||
|
self.assertRaisesRegex(PermissionException, '当前测试批次不可用') as raised,
|
||||||
|
):
|
||||||
|
await TestBatchAccessService.resolve_scope(self.db, make_current_user(8), 7, write=False)
|
||||||
|
|
||||||
|
self.assertEqual({'reason': 'TEST_BATCH_UNAVAILABLE'}, raised.exception.data)
|
||||||
|
|
||||||
|
async def test_purged_batch_is_unavailable(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
TestBatchDao,
|
||||||
|
'get_batch',
|
||||||
|
AsyncMock(return_value=SimpleNamespace(batch_id=7, status='PURGED')),
|
||||||
|
),
|
||||||
|
self.assertRaisesRegex(PermissionException, '当前测试批次不可用') as raised,
|
||||||
|
):
|
||||||
|
await TestBatchAccessService.resolve_scope(self.db, make_admin(), 7, write=False)
|
||||||
|
|
||||||
|
self.assertEqual({'reason': 'TEST_BATCH_UNAVAILABLE'}, raised.exception.data)
|
||||||
|
|
||||||
|
async def test_closed_batch_is_unavailable_for_business_read(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
TestBatchDao,
|
||||||
|
'get_batch',
|
||||||
|
AsyncMock(return_value=SimpleNamespace(batch_id=7, status='CLOSED')),
|
||||||
|
),
|
||||||
|
self.assertRaisesRegex(PermissionException, '当前测试批次不可用') as raised,
|
||||||
|
):
|
||||||
|
await TestBatchAccessService.resolve_scope(self.db, make_admin(), 7, write=False)
|
||||||
|
|
||||||
|
self.assertEqual({'reason': 'TEST_BATCH_UNAVAILABLE'}, raised.exception.data)
|
||||||
|
|
||||||
|
async def test_closed_batch_rejects_business_write(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
TestBatchDao,
|
||||||
|
'get_batch',
|
||||||
|
AsyncMock(return_value=SimpleNamespace(batch_id=7, status='CLOSED')),
|
||||||
|
),
|
||||||
|
self.assertRaisesRegex(PermissionException, '当前测试批次已关闭') as raised,
|
||||||
|
):
|
||||||
|
await TestBatchAccessService.resolve_scope(self.db, make_admin(), 7, write=True)
|
||||||
|
|
||||||
|
self.assertEqual({'reason': 'TEST_BATCH_UNAVAILABLE'}, raised.exception.data)
|
||||||
|
|
||||||
|
async def test_unauthorized_closed_batch_does_not_disclose_status(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
TestBatchDao,
|
||||||
|
'get_batch',
|
||||||
|
AsyncMock(return_value=SimpleNamespace(batch_id=7, status='CLOSED')),
|
||||||
|
),
|
||||||
|
patch.object(TestBatchDao, 'user_is_authorized', AsyncMock(return_value=False)),
|
||||||
|
self.assertRaises(PermissionException) as raised,
|
||||||
|
):
|
||||||
|
await TestBatchAccessService.resolve_scope(self.db, make_current_user(8), 7, write=True)
|
||||||
|
|
||||||
|
self.assertEqual('当前测试批次不可用', raised.exception.message)
|
||||||
|
|
||||||
|
async def test_formal_scope_does_not_lock_batch(self) -> None:
|
||||||
|
get_batch_for_update = AsyncMock()
|
||||||
|
with patch.object(TestBatchDao, 'get_batch_for_update', get_batch_for_update):
|
||||||
|
batch = await TestBatchAccessService.lock_active_batch(self.db, TestBatchScope.formal())
|
||||||
|
|
||||||
|
self.assertIsNone(batch)
|
||||||
|
get_batch_for_update.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_active_batch_lock_returns_current_batch(self) -> None:
|
||||||
|
expected = SimpleNamespace(batch_id=7, status='ACTIVE')
|
||||||
|
get_batch_for_update = AsyncMock(return_value=expected)
|
||||||
|
with patch.object(TestBatchDao, 'get_batch_for_update', get_batch_for_update):
|
||||||
|
batch = await TestBatchAccessService.lock_active_batch(self.db, make_test_scope(7))
|
||||||
|
|
||||||
|
self.assertIs(expected, batch)
|
||||||
|
get_batch_for_update.assert_awaited_once_with(self.db, 7)
|
||||||
|
|
||||||
|
async def test_batch_closed_after_scope_resolution_rejects_lock(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
TestBatchDao,
|
||||||
|
'get_batch_for_update',
|
||||||
|
AsyncMock(return_value=SimpleNamespace(batch_id=7, status='CLOSED')),
|
||||||
|
),
|
||||||
|
self.assertRaisesRegex(PermissionException, '当前测试批次已关闭') as raised,
|
||||||
|
):
|
||||||
|
await TestBatchAccessService.lock_active_batch(self.db, make_test_scope(7))
|
||||||
|
|
||||||
|
self.assertEqual({'reason': 'TEST_BATCH_UNAVAILABLE'}, raised.exception.data)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
@staticmethod
|
||||||
|
def db_with_result(result: MagicMock) -> AsyncMock:
|
||||||
|
db = AsyncMock(spec=AsyncSession)
|
||||||
|
db.execute.return_value = result
|
||||||
|
return db
|
||||||
|
|
||||||
|
async def test_get_batch_filters_by_batch_id_without_locking(self) -> None:
|
||||||
|
expected = SimpleNamespace(batch_id=7)
|
||||||
|
result = MagicMock()
|
||||||
|
result.scalar_one_or_none.return_value = expected
|
||||||
|
db = self.db_with_result(result)
|
||||||
|
|
||||||
|
batch = await TestBatchDao.get_batch(db, 7)
|
||||||
|
|
||||||
|
self.assertIs(expected, batch)
|
||||||
|
sql = compile_sql(db.execute.await_args.args[0])
|
||||||
|
self.assertIn('biz_test_batch.batch_id = 7', sql)
|
||||||
|
self.assertNotIn('FOR UPDATE', sql)
|
||||||
|
db.commit.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_get_batch_for_update_locks_and_refreshes_identity_map(self) -> None:
|
||||||
|
expected = SimpleNamespace(batch_id=7)
|
||||||
|
result = MagicMock()
|
||||||
|
result.scalar_one_or_none.return_value = expected
|
||||||
|
db = self.db_with_result(result)
|
||||||
|
|
||||||
|
batch = await TestBatchDao.get_batch_for_update(db, 7)
|
||||||
|
|
||||||
|
self.assertIs(expected, batch)
|
||||||
|
statement = db.execute.await_args.args[0]
|
||||||
|
self.assertIn('FOR UPDATE', compile_sql(statement))
|
||||||
|
self.assertIs(True, statement.get_execution_options().get('populate_existing'))
|
||||||
|
db.commit.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_user_authorization_checks_batch_and_user_mapping(self) -> None:
|
||||||
|
for scalar, expected in ((7, True), (None, False)):
|
||||||
|
with self.subTest(scalar=scalar):
|
||||||
|
result = MagicMock()
|
||||||
|
result.scalar_one_or_none.return_value = scalar
|
||||||
|
db = self.db_with_result(result)
|
||||||
|
|
||||||
|
authorized = await TestBatchDao.user_is_authorized(db, batch_id=7, user_id=8)
|
||||||
|
|
||||||
|
self.assertIs(expected, authorized)
|
||||||
|
sql = compile_sql(db.execute.await_args.args[0])
|
||||||
|
self.assertIn('biz_test_batch_user.batch_id = 7', sql)
|
||||||
|
self.assertIn('biz_test_batch_user.user_id = 8', sql)
|
||||||
|
db.commit.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_list_available_batches_filters_active_and_orders_newest_first(self) -> None:
|
||||||
|
for is_admin in (False, True):
|
||||||
|
with self.subTest(is_admin=is_admin):
|
||||||
|
first = SimpleNamespace(batch_id=9)
|
||||||
|
second = SimpleNamespace(batch_id=7)
|
||||||
|
result = MagicMock()
|
||||||
|
result.scalars.return_value.all.return_value = (first, second)
|
||||||
|
db = self.db_with_result(result)
|
||||||
|
|
||||||
|
batches = await TestBatchDao.list_available_batches(db, user_id=8, is_admin=is_admin)
|
||||||
|
|
||||||
|
self.assertEqual([first, second], batches)
|
||||||
|
sql = compile_sql(db.execute.await_args.args[0])
|
||||||
|
self.assertIn("biz_test_batch.status = 'ACTIVE'", sql)
|
||||||
|
self.assertIn('ORDER BY biz_test_batch.batch_id DESC', sql)
|
||||||
|
if is_admin:
|
||||||
|
self.assertNotIn('JOIN biz_test_batch_user', sql)
|
||||||
|
else:
|
||||||
|
self.assertIn('JOIN biz_test_batch_user', sql)
|
||||||
|
self.assertIn('biz_test_batch_user.user_id = 8', sql)
|
||||||
|
db.commit.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchDependencyTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self) -> None:
|
||||||
|
RequestContext.clear_all()
|
||||||
|
self.db = AsyncMock(spec=AsyncSession)
|
||||||
|
|
||||||
|
async def asyncTearDown(self) -> None:
|
||||||
|
RequestContext.clear_all()
|
||||||
|
|
||||||
|
async def test_resolver_forwards_write_mode_and_sets_trusted_context(self) -> None:
|
||||||
|
expected = make_test_scope(7)
|
||||||
|
resolve_scope = AsyncMock(return_value=expected)
|
||||||
|
current_user = make_current_user(8)
|
||||||
|
with patch.object(TestBatchAccessService, 'resolve_scope', resolve_scope):
|
||||||
|
scope = await TestBatchScopeResolver(write=True)(make_request('7'), self.db, current_user)
|
||||||
|
|
||||||
|
self.assertIs(expected, scope)
|
||||||
|
self.assertIs(expected, RequestContext.get_current_test_batch_scope())
|
||||||
|
resolve_scope.assert_awaited_once_with(self.db, current_user, 7, write=True)
|
||||||
|
|
||||||
|
async def test_invalid_header_does_not_call_access_service_or_set_context(self) -> None:
|
||||||
|
resolve_scope = AsyncMock()
|
||||||
|
with (
|
||||||
|
patch.object(TestBatchAccessService, 'resolve_scope', resolve_scope),
|
||||||
|
self.assertRaisesRegex(ServiceWarning, '测试批次标识必须为正整数'),
|
||||||
|
):
|
||||||
|
await TestBatchScopeResolver()(make_request('0'), self.db, make_current_user())
|
||||||
|
|
||||||
|
resolve_scope.assert_not_awaited()
|
||||||
|
self.assertIsNone(RequestContext.get_current_test_batch_scope())
|
||||||
|
|
||||||
|
async def test_dependency_factory_wraps_configured_resolver(self) -> None:
|
||||||
|
dependency = TestBatchScopeDependency(write=True)
|
||||||
|
|
||||||
|
self.assertIsInstance(dependency, params.Depends)
|
||||||
|
self.assertIsInstance(dependency.dependency, TestBatchScopeResolver)
|
||||||
|
self.assertIs(True, dependency.dependency.write)
|
||||||
|
|
||||||
|
|
||||||
|
class ContextCleanupMiddlewareTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self) -> None:
|
||||||
|
RequestContext.clear_all()
|
||||||
|
self.middleware = ContextCleanupMiddleware(lambda scope, receive, send: None)
|
||||||
|
self.request = make_request()
|
||||||
|
|
||||||
|
async def asyncTearDown(self) -> None:
|
||||||
|
RequestContext.clear_all()
|
||||||
|
|
||||||
|
def seed_context(self) -> None:
|
||||||
|
RequestContext.set_current_exclude_patterns([{'path': '/private'}])
|
||||||
|
RequestContext.set_current_user(make_admin())
|
||||||
|
RequestContext.set_current_test_batch_scope(make_test_scope(7))
|
||||||
|
|
||||||
|
def assert_context_is_clear(self) -> None:
|
||||||
|
self.assertEqual([], RequestContext.get_current_exclude_patterns())
|
||||||
|
with self.assertRaises(LoginException):
|
||||||
|
RequestContext.get_current_user()
|
||||||
|
self.assertIsNone(RequestContext.get_current_test_batch_scope())
|
||||||
|
|
||||||
|
async def test_dispatch_clears_all_context_after_response(self) -> None:
|
||||||
|
self.seed_context()
|
||||||
|
expected = Response('ok')
|
||||||
|
|
||||||
|
response = await self.middleware.dispatch(self.request, AsyncMock(return_value=expected))
|
||||||
|
|
||||||
|
self.assertIs(expected, response)
|
||||||
|
self.assert_context_is_clear()
|
||||||
|
|
||||||
|
async def test_dispatch_clears_all_context_after_exception(self) -> None:
|
||||||
|
self.seed_context()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'endpoint failed'):
|
||||||
|
await self.middleware.dispatch(
|
||||||
|
self.request,
|
||||||
|
AsyncMock(side_effect=RuntimeError('endpoint failed')),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_context_is_clear()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user