# -*- coding: utf-8 -*- """门店隔离属性测试(Property-Based Testing)。 Property 20: 对于任意两个不同 site_id 的 Operator,一个 Operator 查询 队列/调度/执行历史时,结果中不应包含另一个 site_id 的数据。 Validates: Requirements 1.3 测试策略: - 通过 mock 数据库交互,验证 API 路由在不同 site_id 下的数据隔离 - 队列隔离:为 site_id_a 入队任务,用 site_id_b 的 JWT 查询队列,结果应为空 - 调度隔离:为 site_id_a 创建调度任务,用 site_id_b 的 JWT 查询调度列表,结果应为空 - 执行历史隔离:site_id_a 的执行历史,用 site_id_b 的 JWT 查询不到 """ import os os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-isolation") import json import uuid from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, patch from hypothesis import given, settings, assume from hypothesis import strategies as st from fastapi.testclient import TestClient from app.auth.dependencies import CurrentUser, get_current_user from app.main import app # --------------------------------------------------------------------------- # 通用策略(Strategies) # --------------------------------------------------------------------------- _site_id_st = st.integers(min_value=1, max_value=2**31 - 1) # --------------------------------------------------------------------------- # 辅助函数 # --------------------------------------------------------------------------- def _make_mock_user(site_id: int) -> CurrentUser: """构造指定 site_id 的 mock 用户。""" return CurrentUser(user_id=1, site_id=site_id) def _make_queue_rows(site_id: int, count: int) -> list[tuple]: """生成 count 条属于 site_id 的队列行。""" rows = [] for i in range(count): rows.append(( str(uuid.uuid4()), # id site_id, # site_id json.dumps({"tasks": ["ODS_MEMBER"], "flow": "api_ods"}), # config "pending", # status i + 1, # position datetime(2024, 1, 1, tzinfo=timezone.utc), # created_at None, # started_at None, # finished_at None, # exit_code None, # error_message )) return rows def _make_schedule_rows(site_id: int, count: int) -> list[tuple]: """生成 count 条属于 site_id 的调度行。""" now = datetime.now(timezone.utc) rows = [] for i in range(count): rows.append(( str(uuid.uuid4()), # id site_id, # site_id f"调度任务_{i}", # name ["ODS_MEMBER"], # task_codes {"tasks": ["ODS_MEMBER"], "flow": "api_ods"}, # task_config {"schedule_type": "daily", "daily_time": "04:00", # schedule_config "interval_value": 1, "interval_unit": "hours", "weekly_days": [1], "weekly_time": "04:00", "cron_expression": "0 4 * * *", "enabled": True, "start_date": None, "end_date": None}, True, # enabled None, # last_run_at now + timedelta(hours=1), # next_run_at 0, # run_count None, # last_status now, # created_at now, # updated_at )) return rows def _make_history_rows(site_id: int, count: int) -> list[tuple]: """生成 count 条属于 site_id 的执行历史行。""" base_time = datetime(2024, 1, 1, tzinfo=timezone.utc) rows = [] for i in range(count): rows.append(( str(uuid.uuid4()), # id site_id, # site_id ["ODS_MEMBER"], # task_codes "success", # status base_time + timedelta(hours=i), # started_at base_time + timedelta(hours=i, minutes=30), # finished_at 0, # exit_code 1800000, # duration_ms "python -m cli.main", # command None, # summary )) return rows def _mock_conn_returning(rows: list[tuple]) -> MagicMock: """构造一个 mock connection,其 cursor.fetchall 返回指定行。""" mock_cursor = MagicMock() mock_cursor.fetchall.return_value = rows mock_conn = MagicMock() mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) return mock_conn # --------------------------------------------------------------------------- # Property 20.1: 队列隔离 # **Validates: Requirements 1.3** # --------------------------------------------------------------------------- @settings(max_examples=100, deadline=None) @given( site_id_a=_site_id_st, site_id_b=_site_id_st, queue_count=st.integers(min_value=1, max_value=5), ) @patch("app.services.task_queue.get_connection") def test_queue_isolation(mock_get_conn, site_id_a, site_id_b, queue_count): """Property 20.1: 队列隔离。 为 site_id_a 入队若干任务后,用 site_id_b 的身份查询队列, 结果应为空——不同门店的队列数据互不可见。 """ assume(site_id_a != site_id_b) # site_id_a 的队列数据 rows_a = _make_queue_rows(site_id_a, queue_count) # 核心隔离逻辑:根据查询时传入的 site_id 过滤 # list_pending 内部 SQL: WHERE site_id = %s AND status = 'pending' def conn_for_site(querying_site_id): """模拟数据库行为:只返回匹配 site_id 的行。""" if querying_site_id == site_id_a: return rows_a return [] # site_id_b 查不到 site_id_a 的数据 captured_params = {} def make_mock_conn(): mock_cursor = MagicMock() def execute_side_effect(sql, params=None): if params: captured_params["site_id"] = params[0] # 根据 SQL 中的 site_id 参数返回对应数据 mock_cursor.fetchall.return_value = conn_for_site(params[0]) mock_cursor.execute = MagicMock(side_effect=execute_side_effect) mock_cursor.__enter__ = MagicMock(return_value=mock_cursor) mock_cursor.__exit__ = MagicMock(return_value=False) mock_conn = MagicMock() mock_conn.cursor.return_value = mock_cursor return mock_conn mock_get_conn.return_value = make_mock_conn() # 用 site_id_b 的身份查询队列 app.dependency_overrides[get_current_user] = lambda: _make_mock_user(site_id_b) try: client = TestClient(app) resp = client.get("/api/execution/queue") assert resp.status_code == 200 data = resp.json() # 验证:site_id_b 查不到 site_id_a 的任何数据 assert len(data) == 0, ( f"site_id_b={site_id_b} 不应看到 site_id_a={site_id_a} 的队列数据," f"但返回了 {len(data)} 条记录" ) # 额外验证:即使有数据返回,也不应包含 site_id_a 的记录 for item in data: assert item.get("site_id") != site_id_a, ( f"结果中不应包含 site_id_a={site_id_a} 的数据" ) finally: app.dependency_overrides.clear() # --------------------------------------------------------------------------- # Property 20.2: 调度隔离 # **Validates: Requirements 1.3** # --------------------------------------------------------------------------- @settings(max_examples=100) @given( site_id_a=_site_id_st, site_id_b=_site_id_st, schedule_count=st.integers(min_value=1, max_value=5), ) @patch("app.routers.schedules.get_connection") def test_schedule_isolation(mock_get_conn, site_id_a, site_id_b, schedule_count): """Property 20.2: 调度隔离。 为 site_id_a 创建若干调度任务后,用 site_id_b 的身份查询调度列表, 结果应为空——不同门店的调度数据互不可见。 """ assume(site_id_a != site_id_b) # site_id_a 的调度数据 rows_a = _make_schedule_rows(site_id_a, schedule_count) def make_mock_conn(): mock_cursor = MagicMock() def execute_side_effect(sql, params=None): if params: querying_site_id = params[0] # 只返回匹配 site_id 的行 if querying_site_id == site_id_a: mock_cursor.fetchall.return_value = rows_a else: mock_cursor.fetchall.return_value = [] mock_cursor.execute = MagicMock(side_effect=execute_side_effect) mock_cursor.__enter__ = MagicMock(return_value=mock_cursor) mock_cursor.__exit__ = MagicMock(return_value=False) mock_conn = MagicMock() mock_conn.cursor.return_value = mock_cursor return mock_conn mock_get_conn.return_value = make_mock_conn() # 用 site_id_b 的身份查询调度列表 app.dependency_overrides[get_current_user] = lambda: _make_mock_user(site_id_b) try: client = TestClient(app) resp = client.get("/api/schedules") assert resp.status_code == 200 data = resp.json() # 验证:site_id_b 查不到 site_id_a 的任何调度数据 assert len(data) == 0, ( f"site_id_b={site_id_b} 不应看到 site_id_a={site_id_a} 的调度数据," f"但返回了 {len(data)} 条记录" ) # 额外验证:即使有数据返回,也不应包含 site_id_a 的记录 for item in data: assert item.get("site_id") != site_id_a, ( f"结果中不应包含 site_id_a={site_id_a} 的调度数据" ) finally: app.dependency_overrides.clear() # --------------------------------------------------------------------------- # Property 20.3: 执行历史隔离 # **Validates: Requirements 1.3** # --------------------------------------------------------------------------- @settings(max_examples=100, deadline=None) @given( site_id_a=_site_id_st, site_id_b=_site_id_st, history_count=st.integers(min_value=1, max_value=10), ) @patch("app.routers.execution.get_connection") def test_execution_history_isolation(mock_get_conn, site_id_a, site_id_b, history_count): """Property 20.3: 执行历史隔离。 site_id_a 有若干执行历史记录,用 site_id_b 的身份查询执行历史, 结果应为空——不同门店的执行历史互不可见。 """ assume(site_id_a != site_id_b) # site_id_a 的执行历史数据 rows_a = _make_history_rows(site_id_a, history_count) def make_mock_conn(): mock_cursor = MagicMock() def execute_side_effect(sql, params=None): if params: querying_site_id = params[0] # 只返回匹配 site_id 的行 if querying_site_id == site_id_a: mock_cursor.fetchall.return_value = rows_a else: mock_cursor.fetchall.return_value = [] mock_cursor.execute = MagicMock(side_effect=execute_side_effect) mock_cursor.__enter__ = MagicMock(return_value=mock_cursor) mock_cursor.__exit__ = MagicMock(return_value=False) mock_conn = MagicMock() mock_conn.cursor.return_value = mock_cursor return mock_conn mock_get_conn.return_value = make_mock_conn() # 用 site_id_b 的身份查询执行历史 app.dependency_overrides[get_current_user] = lambda: _make_mock_user(site_id_b) try: client = TestClient(app) resp = client.get("/api/execution/history") assert resp.status_code == 200 data = resp.json() # 验证:site_id_b 查不到 site_id_a 的任何执行历史 assert len(data) == 0, ( f"site_id_b={site_id_b} 不应看到 site_id_a={site_id_a} 的执行历史," f"但返回了 {len(data)} 条记录" ) # 额外验证:即使有数据返回,也不应包含 site_id_a 的记录 for item in data: assert item.get("site_id") != site_id_a, ( f"结果中不应包含 site_id_a={site_id_a} 的执行历史" ) finally: app.dependency_overrides.clear()